Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20e2331560 | ||
|
|
bb140a8734 | ||
|
|
28b600304f | ||
|
|
8fbf4d92cb | ||
|
|
d32ca13d00 | ||
|
|
2a3f08f8a4 | ||
|
|
68ca1d585d | ||
|
|
0815445aa7 | ||
|
|
048c99ebcc | ||
|
|
34026d22b4 | ||
|
|
aeb29f916b | ||
|
|
f83c7ed1f0 | ||
|
|
b313b61717 | ||
|
|
fb6bd5cae1 | ||
|
|
da6b039b29 | ||
|
|
080cdbf383 | ||
|
|
6b7ce512ed | ||
|
|
55b37ba2f4 | ||
|
|
d52470e0cd | ||
|
|
e12f0065a6 | ||
|
|
63d4df0cde | ||
|
|
6b90582e3e | ||
|
|
ea3c765561 | ||
|
|
ac3cd67164 | ||
|
|
f5bee069c0 | ||
|
|
adcdadfcaf | ||
|
|
6406ca3fad | ||
|
|
4af6ed0f98 | ||
|
|
164157f98e | ||
|
|
95eb16d5ef |
@@ -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=
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
#
|
||||
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
|
||||
# scripts/extract-traces.test.ts fails if the two drift apart.
|
||||
MIN_THRESHOLD=82
|
||||
MIN_THRESHOLD=88
|
||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
||||
exit 1
|
||||
|
||||
@@ -30,11 +30,6 @@ coverage
|
||||
.nyc_output
|
||||
*.lcov
|
||||
|
||||
# WebdriverIO E2E tests
|
||||
e2e/logs/
|
||||
e2e/screenshots/
|
||||
wdio-*.log
|
||||
|
||||
# Vitest
|
||||
.vitest
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Dependencies & build output
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
|
||||
# Scratch worktrees (git-ignored) — full checkouts of this repo
|
||||
.claude/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
/package/
|
||||
|
||||
# Rust backend (rustfmt owns this tree)
|
||||
src-tauri/
|
||||
|
||||
# Generated by tauri-specta — regenerated on every Rust build, never hand-edited
|
||||
src/lib/api/bindings.ts
|
||||
|
||||
# Lockfiles and generated data
|
||||
bun.lock
|
||||
*.lcov
|
||||
|
||||
# Generated docs (built by the publish-docs CI job)
|
||||
docs/SUMMARY.md
|
||||
docs/README.md
|
||||
docs/api-redirect.md
|
||||
docs-site/book/
|
||||
|
||||
# Hand-maintained Markdown (docs/, CHANGELOG.md, README.md, ...). Prettier
|
||||
# reflows tables and wrapped prose, which would swamp real doc diffs and fight
|
||||
# the hand-tuned layout of docs/requirements.md and docs/traceability.md
|
||||
# (the latter is generated by scripts/extract-traces.ts).
|
||||
**/*.md
|
||||
|
||||
# CI workflow YAML — formatting churn here would obscure real pipeline diffs.
|
||||
.gitea/
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": { "parser": "svelte" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -52,13 +52,34 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Install Rust using rustup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
||||
# Install Rust using rustup, pinned to an exact release.
|
||||
#
|
||||
# 🔴 RUST_VERSION must equal `channel` in src-tauri/rust-toolchain.toml.
|
||||
#
|
||||
# The two are a pair. rust-toolchain.toml is what makes a developer's `cargo
|
||||
# clippy` agree with CI's; this line is what makes the image already contain that
|
||||
# toolchain. If they drift, rustup silently downloads the pinned version the
|
||||
# first time cargo runs inside a job — a toolchain install at job time, which
|
||||
# CLAUDE.md's "🔴 CI installs no system tools" rule forbids (and which costs
|
||||
# ~1min plus a network dependency on every build).
|
||||
#
|
||||
# 🔴 Changing this line does NOT change CI on its own: the image must be
|
||||
# rebuilt and pushed (`scripts/build-builder-image.sh`) before the new pin is
|
||||
# authoritative. Bump rust-toolchain.toml and this line together, rebuild, push,
|
||||
# then merge.
|
||||
#
|
||||
# Was: `sh -s -- -y` (latest stable, whatever it happened to be on rebuild day).
|
||||
ENV RUST_VERSION=1.97.1
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain "$RUST_VERSION" && \
|
||||
. $HOME/.cargo/env && \
|
||||
rustup default "$RUST_VERSION" && \
|
||||
rustup target add aarch64-linux-android && \
|
||||
rustup target add armv7-linux-androideabi && \
|
||||
rustup target add x86_64-linux-android && \
|
||||
rustup component add rustfmt clippy
|
||||
rustup component add rustfmt clippy && \
|
||||
rustc --version && \
|
||||
cargo clippy --version
|
||||
|
||||
# Setup Android SDK
|
||||
RUN mkdir -p $ANDROID_HOME && \
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Duncan Tourolle
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+17
-4
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
# E2E Test Configuration
|
||||
# Copy this file to .env and fill in your test credentials
|
||||
|
||||
# Jellyfin Server Configuration
|
||||
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
||||
TEST_SERVER_NAME=Demo Server
|
||||
|
||||
# Test User Credentials
|
||||
TEST_USERNAME=demo
|
||||
TEST_PASSWORD=
|
||||
|
||||
# Optional: Specific test data IDs (for testing playback, etc.)
|
||||
# You can find these IDs in your Jellyfin server
|
||||
TEST_MUSIC_LIBRARY_ID=
|
||||
TEST_MOVIE_LIBRARY_ID=
|
||||
TEST_ARTIST_ID=
|
||||
TEST_ALBUM_ID=
|
||||
TEST_TRACK_ID=
|
||||
TEST_MOVIE_ID=
|
||||
TEST_EPISODE_ID=
|
||||
|
||||
# Test Timeouts (milliseconds)
|
||||
TEST_TIMEOUT=60000
|
||||
TEST_WAIT_TIMEOUT=15000
|
||||
-376
@@ -1,376 +0,0 @@
|
||||
# E2E Testing with WebdriverIO
|
||||
|
||||
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure test credentials (first time only)
|
||||
cp e2e/.env.example e2e/.env
|
||||
# Edit e2e/.env with your Jellyfin server details
|
||||
|
||||
# 2. Build the frontend
|
||||
bun run build
|
||||
|
||||
# 3. Run E2E tests
|
||||
bun run test:e2e
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Test Credentials
|
||||
|
||||
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
||||
|
||||
```bash
|
||||
cp e2e/.env.example e2e/.env
|
||||
```
|
||||
|
||||
**e2e/.env** (your private file):
|
||||
```bash
|
||||
# Your Jellyfin test server
|
||||
TEST_SERVER_URL=https://your-jellyfin.example.com
|
||||
TEST_SERVER_NAME=My Test Server
|
||||
|
||||
# Test user credentials
|
||||
TEST_USERNAME=testuser
|
||||
TEST_PASSWORD=yourpassword
|
||||
|
||||
# Optional: Specific test data IDs
|
||||
TEST_MUSIC_LIBRARY_ID=abc123
|
||||
TEST_ALBUM_ID=xyz789
|
||||
# ... etc
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- ✅ `.env` is gitignored - your credentials stay private
|
||||
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
||||
- ✅ Share `.env.example` with your team so they can set up their own
|
||||
|
||||
### Isolated Test Database
|
||||
|
||||
**Your production data is safe!** E2E tests use a completely separate database:
|
||||
|
||||
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
||||
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
||||
|
||||
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── .env.example # Template for test credentials
|
||||
├── .env # Your credentials (gitignored)
|
||||
├── specs/ # Test specifications
|
||||
│ ├── app-launch.e2e.ts # App initialization tests
|
||||
│ ├── auth.e2e.ts # Authentication flow
|
||||
│ └── navigation.e2e.ts # Navigation and routing
|
||||
├── pageobjects/ # Page Object Model (POM)
|
||||
│ ├── BasePage.ts # Base class with common methods
|
||||
│ ├── LoginPage.ts # Login page interactions
|
||||
│ └── HomePage.ts # Home page interactions
|
||||
└── helpers/ # Test utilities
|
||||
├── testConfig.ts # Load .env configuration
|
||||
└── testSetup.ts # Setup helpers
|
||||
```
|
||||
|
||||
### Page Object Model
|
||||
|
||||
Tests use the Page Object Model pattern for maintainability:
|
||||
|
||||
```typescript
|
||||
// Good: Using page objects
|
||||
import LoginPage from "../pageobjects/LoginPage";
|
||||
|
||||
await LoginPage.waitForLoginPage();
|
||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||
await LoginPage.login(testConfig.username, testConfig.password);
|
||||
|
||||
// Bad: Direct selectors in tests
|
||||
await $("#server-url").setValue("https://...");
|
||||
await $("button").click();
|
||||
```
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Using Test Configuration
|
||||
|
||||
Always use `testConfig` for credentials and server details:
|
||||
|
||||
```typescript
|
||||
import { testConfig } from "../helpers/testConfig";
|
||||
|
||||
describe("My Feature", () => {
|
||||
it("should test something", async () => {
|
||||
// Use testConfig instead of hardcoded values
|
||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||
await LoginPage.login(testConfig.username, testConfig.password);
|
||||
|
||||
// Access optional test data
|
||||
if (testConfig.albumId) {
|
||||
// Test with specific album
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test Data IDs
|
||||
|
||||
For tests that need specific content (albums, tracks, etc.):
|
||||
|
||||
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
||||
2. Add it to your `e2e/.env`:
|
||||
```bash
|
||||
TEST_ALBUM_ID=abc123def456
|
||||
```
|
||||
3. Use it in tests:
|
||||
```typescript
|
||||
if (testConfig.albumId) {
|
||||
await browser.url(`/album/${testConfig.albumId}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
import { expect } from "@wdio/globals";
|
||||
import LoginPage from "../pageobjects/LoginPage";
|
||||
import { testConfig } from "../helpers/testConfig";
|
||||
|
||||
describe("Album Playback", () => {
|
||||
beforeEach(async () => {
|
||||
// Login before each test
|
||||
await LoginPage.waitForLoginPage();
|
||||
await LoginPage.fullLoginFlow(
|
||||
testConfig.serverUrl,
|
||||
testConfig.username,
|
||||
testConfig.password
|
||||
);
|
||||
});
|
||||
|
||||
it("should play an album", async () => {
|
||||
// Skip if no test album configured
|
||||
if (!testConfig.albumId) {
|
||||
console.log("Skipping - no TEST_ALBUM_ID configured");
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to album
|
||||
await browser.url(`/album/${testConfig.albumId}`);
|
||||
|
||||
// Click play
|
||||
const playButton = await $('[aria-label="Play"]');
|
||||
await playButton.click();
|
||||
|
||||
// Verify playback started
|
||||
const miniPlayer = await $(".mini-player");
|
||||
expect(await miniPlayer.isDisplayed()).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
bun run test:e2e
|
||||
|
||||
# Run in watch mode (development)
|
||||
bun run test:e2e:dev
|
||||
|
||||
# Run specific test file
|
||||
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
||||
```
|
||||
|
||||
### Before Running
|
||||
|
||||
**Always build the frontend first:**
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
cd src-tauri && cargo build
|
||||
```
|
||||
|
||||
The debug binary expects built frontend files in the `build/` directory.
|
||||
|
||||
## Test Files
|
||||
|
||||
### app-launch.e2e.ts
|
||||
Basic app initialization tests:
|
||||
- App launches successfully
|
||||
- UI renders correctly
|
||||
- Unauthenticated users redirect to login
|
||||
|
||||
**Status:** ✅ Working (no credentials needed)
|
||||
|
||||
### auth.e2e.ts
|
||||
Full authentication flow:
|
||||
- Server connection (2-step process)
|
||||
- Login form validation
|
||||
- Error handling
|
||||
- Complete auth flow
|
||||
|
||||
**Status:** ✅ Working with any Jellyfin server
|
||||
|
||||
### navigation.e2e.ts
|
||||
Routing and navigation:
|
||||
- Protected routes
|
||||
- Redirects
|
||||
- Navigation after login
|
||||
|
||||
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### wdio.conf.ts
|
||||
|
||||
Main WebdriverIO configuration:
|
||||
|
||||
```typescript
|
||||
{
|
||||
port: 4444, // tauri-driver port
|
||||
maxInstances: 1, // Run tests sequentially
|
||||
logLevel: "warn", // Reduce noise
|
||||
framework: "mocha",
|
||||
timeout: 60000, // 60s test timeout
|
||||
|
||||
capabilities: [{
|
||||
"tauri:options": {
|
||||
application: "path/to/app",
|
||||
env: {
|
||||
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
||||
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
||||
| `TEST_USERNAME` | Test user username | `demo` |
|
||||
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
||||
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
||||
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
||||
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
||||
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
||||
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
||||
|
||||
## Debugging
|
||||
|
||||
### View Application During Tests
|
||||
|
||||
Tests run with a visible window. To pause and inspect:
|
||||
|
||||
```typescript
|
||||
it("debug test", async () => {
|
||||
await LoginPage.waitForLoginPage();
|
||||
|
||||
// Pause for 10 seconds to inspect
|
||||
await browser.pause(10000);
|
||||
|
||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||
});
|
||||
```
|
||||
|
||||
### Check Logs
|
||||
|
||||
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
||||
- **tauri-driver logs:** Stdout/stderr from driver process
|
||||
- **App logs:** Check app console (if running with dev tools)
|
||||
|
||||
### Common Issues
|
||||
|
||||
**"Connection refused" in browser body**
|
||||
- Frontend not built: Run `bun run build`
|
||||
- Solution: Always build before testing
|
||||
|
||||
**"Element not found" errors**
|
||||
- Selector might be wrong
|
||||
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
||||
|
||||
**"Invalid session id"**
|
||||
- Normal when app closes between tests
|
||||
- Each test file gets a fresh app instance
|
||||
|
||||
**Tests fail with "no .env file"**
|
||||
- Copy `e2e/.env.example` to `e2e/.env`
|
||||
- Configure your Jellyfin server details
|
||||
|
||||
**Database still using production data**
|
||||
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
||||
- Rebuild app: `cd src-tauri && cargo build`
|
||||
|
||||
## Platform Support
|
||||
|
||||
### Supported
|
||||
|
||||
- ✅ **Linux** - Primary development platform
|
||||
- ✅ **Windows** - Supported (paths auto-detected)
|
||||
- ✅ **macOS** - Supported (paths auto-detected)
|
||||
|
||||
### Not Supported
|
||||
|
||||
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
||||
- Desktop tests cover 90% of app logic anyway
|
||||
|
||||
## Team Collaboration
|
||||
|
||||
### Sharing Test Configuration
|
||||
|
||||
**DO:**
|
||||
- ✅ Commit `e2e/.env.example` with template values
|
||||
- ✅ Update README when adding new test data requirements
|
||||
- ✅ Use descriptive variable names in `.env.example`
|
||||
|
||||
**DON'T:**
|
||||
- ❌ Commit `e2e/.env` with real credentials
|
||||
- ❌ Hardcode server URLs in test files
|
||||
- ❌ Skip authentication in tests (always test full flows)
|
||||
|
||||
### Setting Up for a New Team Member
|
||||
|
||||
1. **Clone repo**
|
||||
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
||||
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
||||
4. **Build frontend:** `bun run build`
|
||||
5. **Run tests:** `bun run test:e2e`
|
||||
|
||||
That's it! No shared credentials needed.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use testConfig:** Never hardcode credentials
|
||||
2. **Use Page Objects:** Keep selectors out of test specs
|
||||
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
||||
4. **Independent Tests:** Each test should work standalone
|
||||
5. **Skip Gracefully:** Check for optional test data before using
|
||||
6. **Build First:** Always `bun run build` before running tests
|
||||
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
||||
- [ ] Create test data fixtures
|
||||
- [ ] Add visual regression testing
|
||||
- [ ] Mock Jellyfin API for faster, more reliable tests
|
||||
- [ ] CI/CD integration (GitHub Actions)
|
||||
- [ ] Test report generation
|
||||
- [ ] Screenshot capture on failure
|
||||
- [ ] Video recording of test runs
|
||||
|
||||
## Resources
|
||||
|
||||
- [WebdriverIO Documentation](https://webdriver.io/)
|
||||
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
||||
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
||||
- [Mocha Documentation](https://mochajs.org/)
|
||||
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
||||
@@ -1,105 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Test configuration loaded from .env file
|
||||
*/
|
||||
export interface TestConfig {
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
musicLibraryId?: string;
|
||||
movieLibraryId?: string;
|
||||
artistId?: string;
|
||||
albumId?: string;
|
||||
trackId?: string;
|
||||
movieId?: string;
|
||||
episodeId?: string;
|
||||
timeout: number;
|
||||
waitTimeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load test configuration from .env file
|
||||
* Falls back to demo server if .env doesn't exist
|
||||
*/
|
||||
export function loadTestConfig(): TestConfig {
|
||||
const envPath = path.join(__dirname, "..", ".env");
|
||||
const config: TestConfig = {
|
||||
serverUrl: "https://demo.jellyfin.org/stable",
|
||||
serverName: "Demo Server",
|
||||
username: "demo",
|
||||
password: "",
|
||||
timeout: 60000,
|
||||
waitTimeout: 15000,
|
||||
};
|
||||
|
||||
// Try to load .env file
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||
const lines = envContent.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.trim().startsWith("#") || !line.trim()) continue;
|
||||
|
||||
const [key, ...valueParts] = line.split("=");
|
||||
const value = valueParts.join("=").trim();
|
||||
|
||||
switch (key.trim()) {
|
||||
case "TEST_SERVER_URL":
|
||||
if (value) config.serverUrl = value;
|
||||
break;
|
||||
case "TEST_SERVER_NAME":
|
||||
if (value) config.serverName = value;
|
||||
break;
|
||||
case "TEST_USERNAME":
|
||||
if (value) config.username = value;
|
||||
break;
|
||||
case "TEST_PASSWORD":
|
||||
config.password = value; // Can be empty
|
||||
break;
|
||||
case "TEST_MUSIC_LIBRARY_ID":
|
||||
if (value) config.musicLibraryId = value;
|
||||
break;
|
||||
case "TEST_MOVIE_LIBRARY_ID":
|
||||
if (value) config.movieLibraryId = value;
|
||||
break;
|
||||
case "TEST_ARTIST_ID":
|
||||
if (value) config.artistId = value;
|
||||
break;
|
||||
case "TEST_ALBUM_ID":
|
||||
if (value) config.albumId = value;
|
||||
break;
|
||||
case "TEST_TRACK_ID":
|
||||
if (value) config.trackId = value;
|
||||
break;
|
||||
case "TEST_MOVIE_ID":
|
||||
if (value) config.movieId = value;
|
||||
break;
|
||||
case "TEST_EPISODE_ID":
|
||||
if (value) config.episodeId = value;
|
||||
break;
|
||||
case "TEST_TIMEOUT":
|
||||
if (value) config.timeout = parseInt(value, 10);
|
||||
break;
|
||||
case "TEST_WAIT_TIMEOUT":
|
||||
if (value) config.waitTimeout = parseInt(value, 10);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
"⚠️ No e2e/.env file found. Using demo server credentials."
|
||||
);
|
||||
console.warn(
|
||||
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const testConfig = loadTestConfig();
|
||||
@@ -1,53 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
/**
|
||||
* Clears the JellyTau database and cache before tests
|
||||
* This ensures each test run starts with a fresh state
|
||||
*/
|
||||
export function clearAppData() {
|
||||
const appDataDir = path.join(
|
||||
os.homedir(),
|
||||
".local/share/com.dtourolle.jellytau"
|
||||
);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(appDataDir)) {
|
||||
// Remove database file
|
||||
const dbPath = path.join(appDataDir, "jellytau.db");
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.unlinkSync(dbPath);
|
||||
console.log("Cleared test database");
|
||||
}
|
||||
|
||||
// Clear any cache files if needed
|
||||
// Add more cleanup as needed
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear app data:", error);
|
||||
// Don't fail tests if cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for element with retries
|
||||
* Useful for elements that might take time to appear
|
||||
*/
|
||||
export async function waitForElement(
|
||||
selector: string,
|
||||
timeout: number = 15000,
|
||||
retries: number = 3
|
||||
): Promise<WebdriverIO.Element> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const element = await $(selector);
|
||||
await element.waitForDisplayed({ timeout });
|
||||
return element;
|
||||
} catch (error) {
|
||||
if (i === retries - 1) throw error;
|
||||
await browser.pause(1000);
|
||||
}
|
||||
}
|
||||
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export default class BasePage {
|
||||
async waitForElement(selector: string, timeout: number = 10000) {
|
||||
const element = await $(selector);
|
||||
await element.waitForDisplayed({ timeout });
|
||||
return element;
|
||||
}
|
||||
|
||||
async clickElement(selector: string) {
|
||||
const element = await this.waitForElement(selector);
|
||||
await element.click();
|
||||
}
|
||||
|
||||
async enterText(selector: string, text: string) {
|
||||
const element = await this.waitForElement(selector);
|
||||
await element.setValue(text);
|
||||
}
|
||||
|
||||
async getText(selector: string): Promise<string> {
|
||||
const element = await this.waitForElement(selector);
|
||||
return await element.getText();
|
||||
}
|
||||
|
||||
async isElementDisplayed(selector: string): Promise<boolean> {
|
||||
try {
|
||||
const element = await $(selector);
|
||||
return await element.isDisplayed();
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import BasePage from "./BasePage";
|
||||
|
||||
class HomePage extends BasePage {
|
||||
// Selectors
|
||||
get loadingSpinner() {
|
||||
return $(".animate-spin");
|
||||
}
|
||||
|
||||
get browseLibrariesButton() {
|
||||
return $("button*=Browse all libraries");
|
||||
}
|
||||
|
||||
get offlineBanner() {
|
||||
return $(".bg-amber-600\\/90");
|
||||
}
|
||||
|
||||
// Carousel sections
|
||||
get heroSection() {
|
||||
return $("div"); // Hero banner would need specific selector
|
||||
}
|
||||
|
||||
// Actions
|
||||
async waitForHomePageLoad(timeout: number = 15000) {
|
||||
// Wait for loading spinner to disappear
|
||||
try {
|
||||
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
||||
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
||||
} catch {
|
||||
// Spinner might not appear if page loads quickly
|
||||
}
|
||||
}
|
||||
|
||||
async isOffline(): Promise<boolean> {
|
||||
try {
|
||||
return await this.offlineBanner.isDisplayed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async clickBrowseLibraries() {
|
||||
await this.browseLibrariesButton.click();
|
||||
}
|
||||
|
||||
async hasContent(): Promise<boolean> {
|
||||
// Check if browse button exists (indicates loaded state)
|
||||
try {
|
||||
return await this.browseLibrariesButton.isExisting();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new HomePage();
|
||||
@@ -1,116 +0,0 @@
|
||||
import BasePage from "./BasePage";
|
||||
|
||||
class LoginPage extends BasePage {
|
||||
// Selectors
|
||||
get pageTitle() {
|
||||
return $("h1");
|
||||
}
|
||||
|
||||
get serverUrlInput() {
|
||||
return $("#server-url");
|
||||
}
|
||||
|
||||
get connectButton() {
|
||||
return $('button[type="submit"]');
|
||||
}
|
||||
|
||||
get usernameInput() {
|
||||
return $("#username");
|
||||
}
|
||||
|
||||
get passwordInput() {
|
||||
return $("#password");
|
||||
}
|
||||
|
||||
get signInButton() {
|
||||
return $('button[type="submit"]');
|
||||
}
|
||||
|
||||
get errorMessage() {
|
||||
return $(".bg-red-900\\/50");
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return $("button*=Back");
|
||||
}
|
||||
|
||||
get serverNameDisplay() {
|
||||
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
||||
}
|
||||
|
||||
// Actions
|
||||
async waitForLoginPage(timeout: number = 10000) {
|
||||
await this.serverUrlInput.waitForDisplayed({ timeout });
|
||||
}
|
||||
|
||||
async enterServerUrl(url: string) {
|
||||
await this.serverUrlInput.setValue(url);
|
||||
}
|
||||
|
||||
async clickConnect() {
|
||||
await this.connectButton.click();
|
||||
}
|
||||
|
||||
async connectToServer(url: string) {
|
||||
await this.enterServerUrl(url);
|
||||
await this.clickConnect();
|
||||
|
||||
// Wait for transition to login form
|
||||
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
||||
}
|
||||
|
||||
async enterUsername(username: string) {
|
||||
await this.usernameInput.setValue(username);
|
||||
}
|
||||
|
||||
async enterPassword(password: string) {
|
||||
await this.passwordInput.setValue(password);
|
||||
}
|
||||
|
||||
async clickSignIn() {
|
||||
await this.signInButton.click();
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.enterUsername(username);
|
||||
await this.enterPassword(password);
|
||||
await this.clickSignIn();
|
||||
}
|
||||
|
||||
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
||||
await this.waitForLoginPage();
|
||||
await this.connectToServer(serverUrl);
|
||||
await this.login(username, password);
|
||||
}
|
||||
|
||||
async isOnServerStep(): Promise<boolean> {
|
||||
try {
|
||||
return await this.serverUrlInput.isDisplayed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isOnLoginStep(): Promise<boolean> {
|
||||
try {
|
||||
return await this.usernameInput.isDisplayed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
||||
return await this.errorMessage.getText();
|
||||
}
|
||||
|
||||
async hasError(): Promise<boolean> {
|
||||
try {
|
||||
return await this.errorMessage.isDisplayed();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new LoginPage();
|
||||
@@ -1,39 +0,0 @@
|
||||
import { expect } from "@wdio/globals";
|
||||
|
||||
describe("Application Launch", () => {
|
||||
it("should launch the application", async () => {
|
||||
// Wait for body element to appear
|
||||
const body = await $("body");
|
||||
await body.waitForDisplayed({ timeout: 15000 });
|
||||
|
||||
// Verify app launched successfully
|
||||
expect(await body.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should render the main app container", async () => {
|
||||
// The app has a root div with specific classes
|
||||
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
||||
|
||||
// Verify the main container exists
|
||||
expect(await appContainer.isExisting()).toBe(true);
|
||||
expect(await appContainer.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should show JellyTau branding", async () => {
|
||||
// The app should show JellyTau title on login page (default state)
|
||||
const title = await $("h1");
|
||||
await title.waitForDisplayed({ timeout: 10000 });
|
||||
|
||||
const titleText = await title.getText();
|
||||
expect(titleText).toContain("JellyTau");
|
||||
});
|
||||
|
||||
it("should redirect unauthenticated users to login", async () => {
|
||||
// Wait for login page elements to appear
|
||||
const serverUrlInput = await $("#server-url");
|
||||
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
||||
|
||||
// Verify we're on the login page
|
||||
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { expect } from "@wdio/globals";
|
||||
import LoginPage from "../pageobjects/LoginPage";
|
||||
import { testConfig } from "../helpers/testConfig";
|
||||
|
||||
describe("Authentication Flow", () => {
|
||||
beforeEach(async () => {
|
||||
// Each test starts fresh - app should redirect to login
|
||||
await LoginPage.waitForLoginPage();
|
||||
});
|
||||
|
||||
describe("Server Connection", () => {
|
||||
it("should display the server connection form", async () => {
|
||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
||||
});
|
||||
|
||||
it("should show server URL input field", async () => {
|
||||
const serverInput = await LoginPage.serverUrlInput;
|
||||
|
||||
expect(await serverInput.isDisplayed()).toBe(true);
|
||||
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
||||
});
|
||||
|
||||
it("should have a disabled connect button when URL is empty", async () => {
|
||||
const connectButton = await LoginPage.connectButton;
|
||||
|
||||
// Button should be disabled when input is empty
|
||||
expect(await connectButton.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should enable connect button when URL is entered", async () => {
|
||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||
|
||||
const connectButton = await LoginPage.connectButton;
|
||||
expect(await connectButton.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should show error for invalid server URL", async () => {
|
||||
await LoginPage.enterServerUrl("not-a-valid-url");
|
||||
await LoginPage.clickConnect();
|
||||
|
||||
// Wait for error to appear
|
||||
await browser.pause(2000);
|
||||
|
||||
expect(await LoginPage.hasError()).toBe(true);
|
||||
});
|
||||
|
||||
it("should transition to login form on successful connection", async () => {
|
||||
// Using configured test server
|
||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||
|
||||
// Should now be on login step
|
||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||
expect(await LoginPage.isOnServerStep()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Login", () => {
|
||||
beforeEach(async () => {
|
||||
// Connect to configured test server before each login test
|
||||
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||
});
|
||||
|
||||
it("should display login form after server connection", async () => {
|
||||
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
||||
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
||||
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should show server information", async () => {
|
||||
// Server name and URL should be displayed
|
||||
const serverName = await LoginPage.serverNameDisplay;
|
||||
expect(await serverName.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
it("should have back button to return to server selection", async () => {
|
||||
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
||||
|
||||
await LoginPage.backButton.click();
|
||||
await browser.pause(500);
|
||||
|
||||
// Should be back on server step
|
||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||
});
|
||||
|
||||
it("should disable sign in button when username is empty", async () => {
|
||||
const signInButton = await LoginPage.signInButton;
|
||||
expect(await signInButton.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("should enable sign in button when username is entered", async () => {
|
||||
await LoginPage.enterUsername("demo");
|
||||
|
||||
const signInButton = await LoginPage.signInButton;
|
||||
expect(await signInButton.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("should show error for invalid credentials", async () => {
|
||||
await LoginPage.login("invalid-user", "wrong-password");
|
||||
|
||||
// Wait for error
|
||||
await browser.pause(2000);
|
||||
|
||||
expect(await LoginPage.hasError()).toBe(true);
|
||||
});
|
||||
|
||||
// Enable this test by configuring e2e/.env with valid credentials
|
||||
it.skip("should successfully login with valid credentials", async () => {
|
||||
await LoginPage.login(testConfig.username, testConfig.password);
|
||||
|
||||
// Wait for redirect to home page
|
||||
await browser.pause(3000);
|
||||
|
||||
// Should redirect away from login page
|
||||
const currentUrl = await browser.getUrl();
|
||||
expect(currentUrl).not.toContain("/login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full Authentication Flow", () => {
|
||||
it("should complete full auth flow with test server", async () => {
|
||||
// Test the complete flow
|
||||
await LoginPage.waitForLoginPage();
|
||||
|
||||
// Step 1: Enter server URL
|
||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||
await LoginPage.clickConnect();
|
||||
|
||||
// Wait for transition
|
||||
await browser.pause(2000);
|
||||
|
||||
// Step 2: Should be on login form
|
||||
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||
|
||||
// Step 3: Enter credentials
|
||||
await LoginPage.enterUsername(testConfig.username);
|
||||
await LoginPage.enterPassword(testConfig.password);
|
||||
|
||||
// Verify form is filled
|
||||
const username = await LoginPage.usernameInput.getValue();
|
||||
expect(username).toBe(testConfig.username);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { expect } from "@wdio/globals";
|
||||
import LoginPage from "../pageobjects/LoginPage";
|
||||
import HomePage from "../pageobjects/HomePage";
|
||||
import { testConfig } from "../helpers/testConfig";
|
||||
|
||||
describe("Navigation", () => {
|
||||
it("should redirect unauthenticated users to login", async () => {
|
||||
// App should automatically redirect to login when not authenticated
|
||||
await LoginPage.waitForLoginPage();
|
||||
|
||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||
});
|
||||
|
||||
it("should prevent direct access to protected routes", async () => {
|
||||
// Try to navigate to a protected route
|
||||
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
||||
await browser.pause(1000);
|
||||
|
||||
// Should redirect back to login
|
||||
await LoginPage.waitForLoginPage(5000);
|
||||
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||
});
|
||||
|
||||
// This test requires valid authentication - configure e2e/.env to enable
|
||||
it.skip("should allow navigation after login", async () => {
|
||||
// Login first
|
||||
await LoginPage.fullLoginFlow(
|
||||
testConfig.serverUrl,
|
||||
testConfig.username,
|
||||
testConfig.password
|
||||
);
|
||||
|
||||
// Wait for home page
|
||||
await HomePage.waitForHomePageLoad();
|
||||
|
||||
// Should be able to navigate
|
||||
expect(await HomePage.hasContent()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// ESLint flat config for the JellyTau frontend (Svelte 5 + TypeScript strict).
|
||||
//
|
||||
// TRACES: | DR-205
|
||||
//
|
||||
// Scope: `src/` (the presentation layer), `scripts/` (build tooling), and the
|
||||
// root config files. The Rust backend is linted by clippy, not by this config.
|
||||
//
|
||||
// Formatting is NOT ESLint's job here — `eslint-config-prettier` is applied last
|
||||
// and switches off every stylistic rule that would fight `prettier`. Run
|
||||
// `bun run format` / `bun run format:check` for layout.
|
||||
import js from "@eslint/js";
|
||||
import ts from "typescript-eslint";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import globals from "globals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
export default ts.config(
|
||||
{
|
||||
// Kept in one place so `npx eslint .` and editor integrations agree.
|
||||
ignores: [
|
||||
"node_modules/",
|
||||
".svelte-kit/",
|
||||
// Scratch worktrees (git-ignored) hold full checkouts of this repo,
|
||||
// including their own generated .svelte-kit trees. Without this, `eslint .`
|
||||
// lints every in-flight branch and reports its generated code as ours.
|
||||
".claude/",
|
||||
"build/",
|
||||
"dist/",
|
||||
"coverage/",
|
||||
"package/",
|
||||
"src-tauri/",
|
||||
// Generated by tauri-specta on every Rust build — never hand-edited, and
|
||||
// its shape is dictated by the Rust command definitions.
|
||||
"src/lib/api/bindings.ts",
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 🔴 TEMPORARILY OFF. A parallel migration is moving all ~468 `console.*`
|
||||
// calls in `src/` onto a logger facade. Turning this on before that lands
|
||||
// would paint the tree red and collide with that work.
|
||||
//
|
||||
// 👉 Switch this to "error" (allowing nothing, or at most
|
||||
// `{ allow: ["warn", "error"] }`) once the logger-facade migration is
|
||||
// merged — that is the whole point of the rule being listed here.
|
||||
"no-console": "off",
|
||||
|
||||
// Unused values are a real signal, but `_`-prefixed args are the
|
||||
// established way to say "this parameter exists for the signature".
|
||||
//
|
||||
// ⚠️ warn, not error: the tree carries ~94 genuinely dead bindings (stale
|
||||
// imports, `$state` left over from refactors, unused `catch (e)`). Every
|
||||
// one is a real finding, but fixing them here would mean ~50 unrelated
|
||||
// files in this tooling commit. Clear the backlog, then promote to
|
||||
// "error".
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
destructuredArrayIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
|
||||
// Warn-only rules: each flags something real, but the existing tree has
|
||||
// more instances than can be fixed without swamping unrelated diffs.
|
||||
// Drive these to zero and promote them to "error" — do not delete them.
|
||||
//
|
||||
// `any` at the Tauri IPC boundary, mostly in code predating the
|
||||
// tauri-specta bindings (~25 sites outside tests).
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
// Empty catch/if bodies that swallow an error.
|
||||
"no-empty": ["warn", { allowEmptyCatch: true }],
|
||||
|
||||
// Prefer `import type` so type-only imports are erased cleanly by the
|
||||
// bundler instead of pulling a module in at run time.
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
|
||||
// Not applicable to this app (~130 hits, all no-ops). SvelteKit's
|
||||
// `resolve()` exists so hrefs keep working under a non-empty
|
||||
// `kit.paths.base`; JellyTau is an adapter-static SPA served from the
|
||||
// Tauri webview root and svelte.config.js sets no `base`. Re-enable this
|
||||
// the day a base path is introduced — the rule is otherwise correct.
|
||||
// (Declared here, not in the *.svelte block: `goto()` is also called from
|
||||
// plain .ts modules such as src/lib/utils/navigation.ts.)
|
||||
"svelte/no-navigation-without-resolve": "off",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// Svelte components: the parser needs the project's svelte.config.js so it
|
||||
// resolves preprocessors and Svelte 5 runes the same way the build does.
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser,
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Warn-only — real findings, but each fix is a behavioural refactor that
|
||||
// does not belong in a tooling commit:
|
||||
// require-each-key keyed {#each} changes DOM reuse semantics
|
||||
// prefer-svelte-reactivity Set/Map -> SvelteSet/SvelteMap changes
|
||||
// reactivity, not just syntax
|
||||
// prefer-writable-derived $state + $effect -> writable $derived
|
||||
// no-at-html-tags {@html} sites need an XSS review each
|
||||
"svelte/require-each-key": "warn",
|
||||
"svelte/prefer-svelte-reactivity": "warn",
|
||||
"svelte/prefer-writable-derived": "warn",
|
||||
"svelte/no-at-html-tags": "warn",
|
||||
|
||||
// Warn-only: this rule cannot see the Svelte *compiler's* warning set, so
|
||||
// it reports `<!-- svelte-ignore a11y_… -->` as unused when the compiler
|
||||
// may still be emitting the warning it suppresses. Verify against a real
|
||||
// `bun run check` before deleting any of them.
|
||||
"svelte/no-unused-svelte-ignore": "warn",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// Node-side tooling: build/test scripts and root config files run under
|
||||
// Bun/Node, not in the webview.
|
||||
files: [
|
||||
"scripts/**/*.{ts,js}",
|
||||
"*.config.{ts,js}",
|
||||
"*.config.*.{ts,js}",
|
||||
"svelte.config.js",
|
||||
"eslint.config.js",
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
// Test files: vitest globals are enabled in vitest.config.ts.
|
||||
files: ["**/*.{test,spec}.{ts,js}", "src/test/**/*.{ts,js}"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.vitest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Test doubles legitimately use `any` for partial mocks.
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
// `vi.mock` factories are hoisted above the import graph, so a lazy
|
||||
// `require()` inside one is the documented escape hatch.
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
// Several tests deliberately replay a production assignment sequence
|
||||
// (`currentStreamUrl = newStreamUrl; hasSeeked = false;`) to document the
|
||||
// `$effect` they stand in for. The "useless" write is the subject under
|
||||
// test, not dead code.
|
||||
"no-useless-assignment": "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
+27
-13
@@ -1,7 +1,14 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.8.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
@@ -13,11 +13,26 @@ Run all tests (frontend + Rust backend).
|
||||
### `test-frontend.sh`
|
||||
Run frontend tests only.
|
||||
```bash
|
||||
./scripts/test-frontend.sh # Run all tests
|
||||
./scripts/test-frontend.sh # Single pass (same as `bun run test`)
|
||||
./scripts/test-frontend.sh --watch # Watch mode
|
||||
./scripts/test-frontend.sh --ui # Open UI
|
||||
```
|
||||
|
||||
`bun run test` is `vitest run` — one pass, exit code, done. It used to be bare
|
||||
`vitest`, which parked in watch mode; CLAUDE.md's "Before Committing" list tells
|
||||
people to run it, so it had to terminate. The interactive modes moved to their
|
||||
own entry points:
|
||||
|
||||
| Command | Runs |
|
||||
|---------|------|
|
||||
| `bun run test` | `vitest run` — single pass |
|
||||
| `bun run test:watch` | `vitest` — watch mode |
|
||||
| `bun run test:ui` | `vitest --ui` |
|
||||
| `bun run test:coverage` | `vitest run --coverage` |
|
||||
|
||||
`test-frontend.sh` forwards any extra arguments to vitest and switches to the
|
||||
long-running form automatically when it sees `--watch`, `-w`, or `--ui`.
|
||||
|
||||
### `test-rust.sh`
|
||||
Run Rust tests only.
|
||||
```bash
|
||||
@@ -120,6 +135,59 @@ For details, see:
|
||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
||||
|
||||
## Linting & Formatting
|
||||
|
||||
There is no script wrapper for these — they are plain package.json entries:
|
||||
|
||||
```bash
|
||||
bun run lint # eslint .
|
||||
bun run lint:fix # eslint . --fix
|
||||
bun run format # prettier --write .
|
||||
bun run format:check # prettier --check .
|
||||
```
|
||||
|
||||
Config lives in `eslint.config.js` (flat config: typescript-eslint +
|
||||
eslint-plugin-svelte, tuned for Svelte 5 and TS `strict`), `.prettierrc`, and
|
||||
`.prettierignore`. `src/lib/api/bindings.ts` is excluded from both — it is
|
||||
generated by tauri-specta on every Rust build.
|
||||
|
||||
`bun run lint` is currently **error-clean but not warning-clean**: several rules
|
||||
are deliberately set to `warn` because the existing tree has more hits than a
|
||||
tooling change should touch (unused bindings, `any` at the IPC boundary, unkeyed
|
||||
`{#each}`). Each one is annotated in `eslint.config.js` with why, and the
|
||||
intended end state is `error`. Drive them down; do not delete them.
|
||||
|
||||
`no-console` is switched **off** for now — see the note in `eslint.config.js`.
|
||||
|
||||
## Git Hooks
|
||||
|
||||
### `install-hooks.sh`
|
||||
Point git at the repo's tracked hooks directory (`core.hooksPath`).
|
||||
```bash
|
||||
bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||
```
|
||||
|
||||
### `hooks/pre-commit`
|
||||
Runs the fast half of CLAUDE.md's "Before Committing" list so it is enforced
|
||||
rather than remembered:
|
||||
|
||||
- `bun run check` (svelte-check)
|
||||
- `bun run test` (vitest, single pass)
|
||||
- `scripts/check-frontend-boundary.sh`
|
||||
- `cargo fmt --all -- --check`, **only when staged files touch `src-tauri/`**
|
||||
|
||||
`cargo clippy` and `cargo test` are deliberately *not* in the hook — minutes per
|
||||
commit is how you teach people to reach for `--no-verify`. They run in CI, and
|
||||
locally via `bun run test:all`.
|
||||
|
||||
```bash
|
||||
git commit --no-verify # skip the hook for one commit
|
||||
git config --unset core.hooksPath # uninstall
|
||||
```
|
||||
|
||||
The hook skips itself during a merge, rebase, or cherry-pick, and when nothing
|
||||
is staged.
|
||||
|
||||
## Utility Scripts
|
||||
|
||||
### `clean.sh`
|
||||
@@ -132,8 +200,12 @@ Clean all build artifacts.
|
||||
|
||||
You can also run these via npm/bun:
|
||||
```bash
|
||||
bun run test # Frontend tests (single pass)
|
||||
bun run test:all # All tests
|
||||
bun run test:rust # Rust tests
|
||||
bun run lint # ESLint
|
||||
bun run format:check # Prettier (check only)
|
||||
bun run hooks:install # Install the git hooks
|
||||
bun run android:build # Build Android APK
|
||||
bun run android:deploy # Deploy to device
|
||||
bun run android:dev # Build + deploy debug
|
||||
|
||||
@@ -115,3 +115,7 @@ fi
|
||||
echo ""
|
||||
echo "✅ APK build complete!"
|
||||
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||
|
||||
# Containerised builds run as root against a bind-mounted tree; hand the
|
||||
# artifacts back to the host user. No-op when not root. See DR-213.
|
||||
"$(dirname "$0")/restore-ownership.sh"
|
||||
|
||||
@@ -26,10 +26,26 @@ echo "🏷️ Tagging for registry..."
|
||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
||||
|
||||
# Step 3: Login to registry (if not already logged in)
|
||||
#
|
||||
# `docker info | grep Username` only ever reports a Docker Hub session, so for a
|
||||
# private registry it never matched — meaning this branch fired on every push and
|
||||
# dropped into an interactive `docker login`, which hangs any non-interactive run
|
||||
# (a scripted release, or CI). Check the credential store for this specific
|
||||
# registry instead, and refuse rather than prompt when there is no TTY to
|
||||
# prompt on.
|
||||
echo "🔐 Checking registry authentication..."
|
||||
if ! docker info | grep -q "Username"; then
|
||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
||||
docker login ${REGISTRY_HOST}
|
||||
DOCKER_CFG="${DOCKER_CONFIG:-$HOME/.docker}/config.json"
|
||||
if ! grep -q "\"${REGISTRY_HOST}\"" "$DOCKER_CFG" 2>/dev/null; then
|
||||
if [ -t 0 ]; then
|
||||
echo "Not authenticated to ${REGISTRY_HOST}. Logging in..."
|
||||
docker login "${REGISTRY_HOST}"
|
||||
else
|
||||
echo "❌ Not authenticated to ${REGISTRY_HOST}, and stdin is not a TTY."
|
||||
echo " Run this first: docker login ${REGISTRY_HOST}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo " Using stored credentials for ${REGISTRY_HOST}."
|
||||
fi
|
||||
|
||||
# Step 4: Push to registry
|
||||
|
||||
@@ -42,3 +42,7 @@ if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
echo ""
|
||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||
fi
|
||||
|
||||
# Containerised builds run as root against a bind-mounted tree; hand the
|
||||
# artifacts back to the host user. No-op when not root. See DR-213.
|
||||
"$(dirname "$0")/restore-ownership.sh"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# JellyTau pre-commit hook — the fast half of CLAUDE.md's "Before Committing"
|
||||
# list, enforced instead of remembered.
|
||||
#
|
||||
# TRACES: | DR-207
|
||||
#
|
||||
# Install with: bun run hooks:install (sets core.hooksPath=scripts/hooks)
|
||||
# Skip once with: git commit --no-verify
|
||||
#
|
||||
# What runs here is deliberately limited to gates that finish in seconds:
|
||||
#
|
||||
# bun run check svelte-check (types)
|
||||
# bun run test vitest, single pass
|
||||
# scripts/check-frontend-boundary.sh domain-taxonomy tripwire (DR-094)
|
||||
# cargo fmt --all -- --check only when src-tauri/ is staged
|
||||
#
|
||||
# NOT here, on purpose: `cargo clippy` and `cargo test`. Both take minutes on a
|
||||
# cold target dir, which turns every commit into a coffee break and trains
|
||||
# people to reach for --no-verify. CI (.gitea/workflows/build-and-test.yml) is
|
||||
# where those run; `bun run test:all` is the local equivalent.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Merge and rebase commits carry someone else's changes, and conflict resolution
|
||||
# is exactly when a slow gate is least welcome. Let them through — CI still
|
||||
# gates the merge result.
|
||||
GIT_DIR_PATH="$(git rev-parse --git-dir 2>/dev/null)" || exit 0
|
||||
if [ -e "$GIT_DIR_PATH/MERGE_HEAD" ] ||
|
||||
[ -d "$GIT_DIR_PATH/rebase-merge" ] ||
|
||||
[ -d "$GIT_DIR_PATH/rebase-apply" ] ||
|
||||
[ -e "$GIT_DIR_PATH/CHERRY_PICK_HEAD" ]; then
|
||||
echo "pre-commit: merge/rebase in progress — skipping checks (CI still gates the result)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Nothing staged (e.g. `git commit --amend` that only edits the message): nothing
|
||||
# to check.
|
||||
STAGED="$(git diff --cached --name-only --diff-filter=ACMR)"
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
FAILED=0
|
||||
|
||||
run_gate() {
|
||||
label="$1"
|
||||
shift
|
||||
echo ""
|
||||
echo "🔎 pre-commit: $label"
|
||||
if ! "$@"; then
|
||||
echo "❌ pre-commit: $label failed"
|
||||
FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
run_gate "svelte-check (bun run check)" bun run check
|
||||
run_gate "frontend tests (bun run test)" bun run test
|
||||
run_gate "frontend/backend boundary" bash scripts/check-frontend-boundary.sh
|
||||
|
||||
# rustfmt only matters when Rust actually changed, and `cargo fmt --check` is
|
||||
# cheap (no compilation) whenever it does.
|
||||
if printf '%s\n' "$STAGED" | grep -q '^src-tauri/'; then
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "🔎 pre-commit: rustfmt (src-tauri/ is staged)"
|
||||
if ! (cd src-tauri && cargo fmt --all -- --check); then
|
||||
echo "❌ pre-commit: cargo fmt --all -- --check failed"
|
||||
echo " fix with: cd src-tauri && cargo fmt"
|
||||
FAILED=1
|
||||
fi
|
||||
else
|
||||
echo "⚠️ pre-commit: src-tauri/ staged but cargo is not on PATH — skipping rustfmt."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "🛑 pre-commit checks failed. Fix them, or bypass deliberately with:"
|
||||
echo " git commit --no-verify"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ pre-commit checks passed."
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Point git at the repo's tracked hooks directory.
|
||||
#
|
||||
# TRACES: | DR-207
|
||||
#
|
||||
# bun run hooks:install # or: ./scripts/install-hooks.sh
|
||||
#
|
||||
# `core.hooksPath` is used rather than copying files into .git/hooks so the
|
||||
# hooks stay version-controlled: an update to scripts/hooks/pre-commit reaches
|
||||
# everyone on their next pull instead of needing a re-install.
|
||||
#
|
||||
# The setting is local to this clone (git config, not committed). To undo:
|
||||
# git config --unset core.hooksPath
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
HOOKS_DIR="scripts/hooks"
|
||||
|
||||
if [ ! -d "$HOOKS_DIR" ]; then
|
||||
echo "❌ $HOOKS_DIR does not exist — are you in the JellyTau repo?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Git refuses to run a hook that is not executable, and the bit is easy to lose
|
||||
# on a fresh checkout on some filesystems.
|
||||
chmod +x "$HOOKS_DIR"/* 2>/dev/null || true
|
||||
|
||||
git config core.hooksPath "$HOOKS_DIR"
|
||||
|
||||
echo "✅ core.hooksPath = $(git config core.hooksPath)"
|
||||
echo ""
|
||||
echo "Installed hooks:"
|
||||
for hook in "$HOOKS_DIR"/*; do
|
||||
[ -f "$hook" ] || continue
|
||||
echo " - $(basename "$hook")"
|
||||
done
|
||||
echo ""
|
||||
echo "pre-commit runs: bun run check, bun run test, check-frontend-boundary.sh,"
|
||||
echo "and cargo fmt --check when src-tauri/ is staged."
|
||||
echo "Bypass a single commit with: git commit --no-verify"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Give build artifacts back to the human who owns the working tree.
|
||||
#
|
||||
# TRACES: | DR-213
|
||||
#
|
||||
# The containerised builds (docker-compose.yml: desktop-linux-build,
|
||||
# windows-cross, android-build, test, dev) bind-mount the repo at /app and run
|
||||
# as root, because their caches live at /root/.cargo and /root/.bun. Everything
|
||||
# they write into src-tauri/target and dist/ is therefore root-owned *on the
|
||||
# host* — and it accumulates: one audit found 11,124 such files, which is enough
|
||||
# to make `cargo clean` and scripts/clean.sh fail with EACCES for the developer.
|
||||
# Worse, a plain `cargo build` then dies part-way through, because build scripts
|
||||
# compile for the host and land in target/debug even during a cross-build.
|
||||
#
|
||||
# Running the containers as the host uid would be the tidier fix, but it needs
|
||||
# the cache volumes relocated off /root first. Until that happens, this restores
|
||||
# ownership at the end of each containerised build, which is self-healing and
|
||||
# needs no uid plumbing on the host side.
|
||||
#
|
||||
# Outside a container this is a no-op: it exits immediately unless it is running
|
||||
# as root, so the native build scripts can call it unconditionally.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Not root (a normal developer build) — nothing to fix, and nothing we may fix.
|
||||
[ "$(id -u)" -eq 0 ] || exit 0
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT="$(pwd)"
|
||||
|
||||
# Whoever owns the checkout is who the artifacts should belong to. Reading it
|
||||
# from the tree means this works for any uid/gid without being told, including
|
||||
# CI runners whose uid we do not control.
|
||||
OWNER="$(stat -c '%u:%g' "$REPO_ROOT")"
|
||||
|
||||
# uid 0 owning the tree means it is not a bind mount from a normal host account
|
||||
# (a root-owned checkout, or a CI image that clones as root). Nothing to give back.
|
||||
if [ "${OWNER%%:*}" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔑 Restoring ownership of build artifacts to ${OWNER}…"
|
||||
|
||||
for target in src-tauri/target src-tauri/gen dist build node_modules .svelte-kit; do
|
||||
[ -e "$REPO_ROOT/$target" ] || continue
|
||||
chown -R "$OWNER" "$REPO_ROOT/$target" 2>/dev/null || {
|
||||
echo "⚠️ Could not fully chown $target — you may need:"
|
||||
echo " sudo chown -R $OWNER $REPO_ROOT/$target"
|
||||
}
|
||||
done
|
||||
|
||||
echo "✅ Ownership restored."
|
||||
+6
-2
@@ -7,7 +7,9 @@ echo "🧪 Running all tests..."
|
||||
echo ""
|
||||
|
||||
echo "📦 Running frontend tests..."
|
||||
bun run test --run
|
||||
# `bun run test` is `vitest run` (single pass). It used to be bare `vitest`,
|
||||
# which needed an explicit `--run` here to avoid parking CI in watch mode.
|
||||
bun run test
|
||||
|
||||
echo ""
|
||||
echo "🦀 Running Rust tests..."
|
||||
@@ -19,7 +21,9 @@ echo ""
|
||||
echo "🚧 Checking architectural gates..."
|
||||
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
|
||||
bun run check:boundary
|
||||
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
|
||||
# Traceability coverage (DR-093): fails below the ratchet in
|
||||
# .gitea/workflows/traceability-check.yml (MIN_THRESHOLD, currently 88%), or
|
||||
# above 100% (miscount).
|
||||
bun run traces:coverage
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Run frontend tests only
|
||||
# Run frontend tests only.
|
||||
#
|
||||
# `bun run test` is a single pass (`vitest run`), which is what CI and the
|
||||
# pre-commit hook want. This wrapper keeps the interactive modes reachable:
|
||||
# pass --watch or --ui and vitest is invoked in its long-running form instead.
|
||||
# Any other arguments (test-name filters, path filters, --reporter, ...) are
|
||||
# forwarded to the single-pass run.
|
||||
|
||||
set -e
|
||||
|
||||
echo "📦 Running frontend tests..."
|
||||
bun run test "$@"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--watch | --ui | -w)
|
||||
exec bunx vitest "$@"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec bunx vitest run "$@"
|
||||
|
||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.8.2"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
# Pinned Rust toolchain for the JellyTau backend.
|
||||
#
|
||||
# TRACES: | DR-206
|
||||
#
|
||||
# Why pin: the toolchain was unpinned, so the CI builder image (rustc 1.97.1)
|
||||
# and developer machines (as low as 1.92.0) were five releases apart. Clippy's
|
||||
# lint set and rustfmt's output both move between releases, which means a green
|
||||
# `cargo clippy` / `cargo fmt --check` locally proved nothing about CI — and vice
|
||||
# versa. Everything in this file exists to make both sides run the same compiler.
|
||||
#
|
||||
# 🔴 This value MUST match the rustc that Dockerfile.builder installs (see
|
||||
# RUST_VERSION there). If they drift, rustup downloads the pinned toolchain at
|
||||
# job time inside the container — a toolchain install in CI, which is exactly
|
||||
# what CLAUDE.md's "CI installs no system tools" rule forbids. To move the pin:
|
||||
# bump BOTH this file and Dockerfile.builder, then rebuild and push the image
|
||||
# with scripts/build-builder-image.sh before merging.
|
||||
#
|
||||
# No `targets` key on purpose: listing the Android/Windows targets here would
|
||||
# make rustup fetch all of them on every plain `cargo test`, including on
|
||||
# machines that never cross-compile. The builder image already carries them
|
||||
# (`rustup target add` in Dockerfile.builder), and the cross-build scripts add
|
||||
# them locally when needed.
|
||||
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -3,7 +3,7 @@
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
@@ -132,6 +132,73 @@ fn sanitize_filename(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The directory every download has to stay inside: the storage root
|
||||
/// `storage_get_path` hands the frontend, which is the database's parent.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
database
|
||||
.path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())
|
||||
}
|
||||
|
||||
/// Fold `..` out of `candidate` and require what is left to sit inside `root`.
|
||||
///
|
||||
/// Lexical rather than `canonicalize`, the same way `media_server::resolve_path`
|
||||
/// does it: the file usually does not exist yet, so canonicalising would fail on
|
||||
/// the ordinary case. The check has to come *after* the caller's join, because
|
||||
/// `Path::join` drops the base when the joined half is absolute — such a path is
|
||||
/// not folded, it is obeyed, and only the `starts_with` below catches it.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
|
||||
let mut resolved = PathBuf::new();
|
||||
for component in candidate.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => resolved.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.starts_with(root) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Refusing a download path outside the download directory: {}",
|
||||
candidate.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a queued download's path and confine it to the download directory.
|
||||
///
|
||||
/// Every path the app builds for itself comes back unchanged — files on disk and
|
||||
/// `downloads` rows point at these exact spellings — and [`sanitize_filename`]
|
||||
/// is idempotent, so the already-safe name `download_item_and_start` passes in
|
||||
/// is not sanitized into a second, different one.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
|
||||
let mut sanitized = PathBuf::new();
|
||||
for component in Path::new(file_path).components() {
|
||||
match component {
|
||||
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
|
||||
// Kept as they are, so `confine_to_root` is the single thing
|
||||
// deciding whether what they add up to is still inside the root.
|
||||
other => sanitized.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
confine_to_root(root, &root.join(&sanitized))?;
|
||||
Ok(sanitized.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||
/// 10-argument command limit).
|
||||
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||
@@ -253,6 +320,18 @@ pub async fn download_item(
|
||||
album_name,
|
||||
expected_size,
|
||||
} = request;
|
||||
|
||||
// `start_download` joins this onto the target directory, and `Path::join`
|
||||
// drops the base when the second half is absolute, so the row itself has to
|
||||
// be confined — not only the place it is used. `download_item_and_start`
|
||||
// sanitizes the name it builds, but `download_item` is a command in its own
|
||||
// right, so that guard was simply routed around by calling this directly.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_queued_path(&root, &file_path)?
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1286,6 +1365,20 @@ pub async fn mark_download_completed(
|
||||
bytes_downloaded: i64,
|
||||
file_path: String,
|
||||
) -> Result<(), String> {
|
||||
// Deleting a download reads this straight back into `std::fs::remove_file`,
|
||||
// so a row must never come to name a file outside the download directory.
|
||||
// The worker reports the absolute path it wrote, and joining an absolute
|
||||
// path onto the root yields it unchanged, so that case is stored verbatim;
|
||||
// the frontend's fallback to the row's own (relative) path resolves under
|
||||
// the root, where the worker put it.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let file_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &root.join(&file_path))?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -1416,6 +1509,14 @@ pub async fn start_download(
|
||||
item_id, file_path, file_size
|
||||
);
|
||||
|
||||
// Both halves of this join reached us from the frontend, so resolve them
|
||||
// against the download directory before a single byte is written.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let target_path = {
|
||||
let root = download_root(&db)?;
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
|
||||
};
|
||||
|
||||
// Make a HEAD request to get the file size from Content-Length header
|
||||
debug!("Making HEAD request to get file size...");
|
||||
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||
@@ -1489,9 +1590,6 @@ pub async fn start_download(
|
||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||
}
|
||||
|
||||
// Build target path
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
|
||||
// Get a clone of the active downloads Arc for unregistering later
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
@@ -1762,6 +1860,36 @@ pub(crate) async fn pump_download_queue(
|
||||
None => return, // Nothing pending to start
|
||||
};
|
||||
|
||||
// Confine the row's path before it takes a slot. A row whose target
|
||||
// escapes the download directory can never start, so it is failed here
|
||||
// rather than picked again on the next pass — this loop re-queries, so
|
||||
// merely skipping it would not terminate.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let confined = {
|
||||
let db_state = app.state::<DatabaseWrapper>();
|
||||
download_root(&db_state).and_then(|root| {
|
||||
confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
|
||||
})
|
||||
};
|
||||
let target_path = match confined {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
error!("[pump] Refusing download {}: {}", download_id, e);
|
||||
let fail_query = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![QueryParam::String(e), QueryParam::Int64(download_id)],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(fail_query).await {
|
||||
error!(
|
||||
"[pump] Failed to mark download {} failed: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Register the slot. If registration fails (race: another pump filled
|
||||
// the last slot), stop — we'll be re-pumped when a slot frees.
|
||||
{
|
||||
@@ -1809,7 +1937,6 @@ pub(crate) async fn pump_download_queue(
|
||||
},
|
||||
);
|
||||
|
||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||
spawn_download_worker(
|
||||
app.clone(),
|
||||
download_id,
|
||||
@@ -2421,6 +2548,99 @@ mod tests {
|
||||
assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
|
||||
}
|
||||
|
||||
/// The download directory as it looks on a device, for the path tests.
|
||||
const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
|
||||
|
||||
/// A queued `file_path` cannot walk out of the download directory.
|
||||
///
|
||||
/// `download_item` is a command in its own right, so sanitizing in
|
||||
/// `download_item_and_start` was routed around by invoking it directly, and
|
||||
/// `start_download` then joined the raw string onto the target directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_cannot_escape_the_download_directory() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
|
||||
assert!(confine_queued_path(root, "../.bashrc").is_err());
|
||||
assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
|
||||
|
||||
// Why the absolute case needs its own guard rather than folding: the
|
||||
// join the download path performs discards the base entirely.
|
||||
//
|
||||
// clippy::join_absolute_paths flags exactly this shape, and is right to
|
||||
// in production code — here the discarded base *is* the assertion, so
|
||||
// the lint is allowed rather than the code changed. Note the lint would
|
||||
// not have caught the original defect: the real join sites take a
|
||||
// variable, and the lint only fires on a literal starting with `/`.
|
||||
#[allow(clippy::join_absolute_paths)]
|
||||
{
|
||||
assert_eq!(
|
||||
PathBuf::from(root).join("/etc/cron.d/pwn"),
|
||||
PathBuf::from("/etc/cron.d/pwn")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths the app builds for itself have to survive unchanged: files are
|
||||
/// already on disk and `downloads` rows point at these exact spellings.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_queued_download_paths_are_otherwise_unchanged() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
for path in [
|
||||
"downloads/9f8e7d6c", // MediaCard's queue-for-reconnect
|
||||
"videos/movies/Arrival.mp4", // VideoDownloadButton
|
||||
"albums/abc123/01 - Opening.mp3", // queue_album_tracks
|
||||
// download_series/download_season build an absolute path, because
|
||||
// their base_path is `${targetDir}/videos`.
|
||||
"/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
|
||||
] {
|
||||
assert_eq!(confine_queued_path(root, path).unwrap(), path);
|
||||
}
|
||||
|
||||
// `download_item_and_start` sanitizes the name before calling
|
||||
// `download_item`; sanitizing it again must not yield a second, different
|
||||
// name, which would orphan the row and the file it names.
|
||||
let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
|
||||
assert_eq!(confine_queued_path(root, &already).unwrap(), already);
|
||||
}
|
||||
|
||||
/// A completed row's `file_path` is read straight back into
|
||||
/// `std::fs::remove_file` when the download is deleted, so `mark_download_completed`
|
||||
/// must not be able to register a file outside the download directory.
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
#[test]
|
||||
fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
|
||||
let root = Path::new(TEST_ROOT);
|
||||
|
||||
// What the worker actually reports — the absolute path it wrote. Stored
|
||||
// exactly as it arrives.
|
||||
let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
|
||||
assert_eq!(
|
||||
confine_to_root(root, Path::new(written)).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
// The row's own path, if the frontend falls back to it: relative, and it
|
||||
// resolves to where the worker wrote the file.
|
||||
assert_eq!(
|
||||
confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
|
||||
PathBuf::from(written)
|
||||
);
|
||||
|
||||
assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
|
||||
assert!(confine_to_root(
|
||||
root,
|
||||
Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// Helper to set up test database with required foreign key data
|
||||
fn setup_test_db() -> Database {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -187,7 +187,7 @@ pub async fn get_album_recommendations(
|
||||
}
|
||||
|
||||
// Sort by tracks played (descending)
|
||||
recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played));
|
||||
recommendations.sort_by_key(|r| std::cmp::Reverse(r.tracks_played));
|
||||
|
||||
Ok(recommendations)
|
||||
}
|
||||
@@ -224,7 +224,7 @@ pub fn get_album_affinity_status(
|
||||
.collect();
|
||||
|
||||
// Sort by play count (descending)
|
||||
statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played));
|
||||
statuses.sort_by_key(|s| std::cmp::Reverse(s.unique_tracks_played));
|
||||
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Library browsing preferences — currently, which folders are hidden.
|
||||
//!
|
||||
//! The setting replaces a hardcoded frontend filter that dropped any item
|
||||
//! literally named "Podcasts", which was one user's folder layout keyed on an
|
||||
//! English string and shipped to everyone. What is hidden is now a user choice
|
||||
//! made of stable ids, applied in the repository layer
|
||||
//! (`repository::exclusions`) so every query path agrees; the frontend only
|
||||
//! renders a picker over the candidates this module serves.
|
||||
//!
|
||||
//! TRACES: UR-076 | DR-209
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::repository::exclusions;
|
||||
use crate::repository::types::{GetItemsOptions, SearchScope};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::settings::LibrarySettings;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// `app_settings` key holding the persisted library preferences (JSON).
|
||||
///
|
||||
/// Persisted for the same reason the streaming cap is: a hidden folder that
|
||||
/// silently comes back on the next launch is a setting the user has to keep
|
||||
/// re-applying, and they would have no way to tell it had been forgotten.
|
||||
const LIBRARY_SETTINGS_KEY: &str = "library_settings";
|
||||
|
||||
/// How many immediate children of a library the picker will consider.
|
||||
///
|
||||
/// A music library's root listing is folders and (on some layouts) artists, not
|
||||
/// the whole catalog, so this is generous. It exists to stop a pathological
|
||||
/// library from turning the settings page into an unbounded fetch.
|
||||
const CANDIDATE_SCAN_LIMIT: usize = 500;
|
||||
|
||||
/// Something the user may choose to hide: a library, or a folder directly
|
||||
/// inside one.
|
||||
///
|
||||
/// Which containers are *offerable* is a domain question (it depends on the
|
||||
/// library's Jellyfin collection type and on what counts as a folder), so the
|
||||
/// list is assembled here and the frontend renders it verbatim.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExclusionCandidate {
|
||||
/// Stable Jellyfin item id — what gets stored when the user picks it.
|
||||
pub id: String,
|
||||
/// Display name of the folder (or of the library, for a whole-library entry).
|
||||
pub name: String,
|
||||
/// Library this candidate lives in, so the picker can group and disambiguate
|
||||
/// two folders that share a name.
|
||||
pub library_name: String,
|
||||
/// True when the candidate *is* a library rather than a folder inside one.
|
||||
pub is_library: bool,
|
||||
}
|
||||
|
||||
/// The library preferences currently in force.
|
||||
///
|
||||
/// Read from the in-memory exclusion set rather than the database: that set is
|
||||
/// what queries actually consult, so reading it is the only answer that cannot
|
||||
/// disagree with what the user is seeing.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_settings() -> Result<LibrarySettings, String> {
|
||||
Ok(LibrarySettings {
|
||||
excluded_item_ids: exclusions::excluded_item_ids(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the library preferences: apply them to every subsequent query and
|
||||
/// persist them.
|
||||
///
|
||||
/// Returns the sanitised value actually applied, so the picker shows what was
|
||||
/// stored rather than what it sent.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_set_settings(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
settings: LibrarySettings,
|
||||
) -> Result<LibrarySettings, String> {
|
||||
let sanitised = settings.sanitised();
|
||||
exclusions::set_excluded_item_ids(&sanitised.excluded_item_ids);
|
||||
persist_library_settings(&db, &sanitised).await;
|
||||
info!(
|
||||
"[Library] {} folder(s) hidden from browsing",
|
||||
sanitised.excluded_item_ids.len()
|
||||
);
|
||||
Ok(sanitised)
|
||||
}
|
||||
|
||||
/// The folders the user may choose to hide.
|
||||
///
|
||||
/// Offers each music library and the folders directly inside it. Music is the
|
||||
/// only scope offered because it is the one where a foreign folder — podcasts,
|
||||
/// audiobooks, sound effects — routinely shares a library with the media the
|
||||
/// user actually browses; the scope is decided here rather than in the UI so the
|
||||
/// collection-type table stays out of the frontend
|
||||
/// (see `SearchScope::for_collection_type`).
|
||||
///
|
||||
/// Reads through `HybridRepository::get_items_unfiltered` so folders that are
|
||||
/// *already* hidden still appear — otherwise the setting could never be undone.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn library_get_exclusion_candidates(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<Vec<ExclusionCandidate>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let libraries = repo
|
||||
.as_ref()
|
||||
.get_libraries()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))?;
|
||||
|
||||
let mut candidates: Vec<ExclusionCandidate> = Vec::new();
|
||||
|
||||
for library in libraries {
|
||||
if SearchScope::for_collection_type(&library.collection_type) != Some(SearchScope::Music) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push(ExclusionCandidate {
|
||||
id: library.id.clone(),
|
||||
name: library.name.clone(),
|
||||
library_name: library.name.clone(),
|
||||
is_library: true,
|
||||
});
|
||||
|
||||
let options = GetItemsOptions {
|
||||
recursive: Some(false),
|
||||
sort_by: Some("SortName".to_string()),
|
||||
sort_order: Some("Ascending".to_string()),
|
||||
limit: Some(CANDIDATE_SCAN_LIMIT),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match repo.get_items_unfiltered(&library.id, Some(options)).await {
|
||||
Ok(result) => {
|
||||
for item in result.items {
|
||||
if !item.is_folder {
|
||||
continue;
|
||||
}
|
||||
candidates.push(ExclusionCandidate {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
library_name: library.name.clone(),
|
||||
is_library: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// One unreachable library must not cost the user the picker for
|
||||
// the others — an empty section is recoverable, an error is not.
|
||||
warn!(
|
||||
"[Library] Could not list folders in {}: {:?}",
|
||||
library.name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[Library] {} exclusion candidate(s)", candidates.len());
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// Write the preferences to `app_settings`.
|
||||
///
|
||||
/// Failure is logged, not returned: the setting has already been applied in
|
||||
/// memory, and failing the whole call because the write failed would leave the
|
||||
/// picker showing a state that *is* in force.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
async fn persist_library_settings(db: &State<'_, DatabaseWrapper>, settings: &LibrarySettings) {
|
||||
let db_service = {
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let encoded = match serde_json::to_string(settings) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[Library] Failed to encode library settings: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(LIBRARY_SETTINGS_KEY.to_string()),
|
||||
QueryParam::String(encoded),
|
||||
],
|
||||
);
|
||||
|
||||
if let Err(e) = db_service.execute(query).await {
|
||||
warn!("[Library] Failed to persist library settings: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the persisted preferences at startup, into the exclusion set the
|
||||
/// repository consults.
|
||||
///
|
||||
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
|
||||
/// default — nothing hidden — in place, so a database problem shows the user
|
||||
/// more than they asked for rather than less.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub async fn restore_library_settings(app: &tauri::AppHandle) {
|
||||
let db_service = {
|
||||
let Some(db) = app.try_state::<DatabaseWrapper>() else {
|
||||
warn!("[Library] No database available; nothing hidden from browsing");
|
||||
return;
|
||||
};
|
||||
let database = db.0.lock_safe();
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(LIBRARY_SETTINGS_KEY.to_string())],
|
||||
);
|
||||
|
||||
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("[Library] Failed to read library settings: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(stored) = stored else { return };
|
||||
let settings: LibrarySettings = match serde_json::from_str(&stored) {
|
||||
Ok(settings) => settings,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Library] Ignoring unreadable persisted library settings {:?}: {}",
|
||||
stored, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let settings = settings.sanitised();
|
||||
exclusions::set_excluded_item_ids(&settings.excluded_item_ids);
|
||||
if !settings.excluded_item_ids.is_empty() {
|
||||
info!(
|
||||
"[Library] Restored {} hidden folder(s)",
|
||||
settings.excluded_item_ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The persisted form must round-trip through the same camelCase JSON the
|
||||
/// IPC boundary uses — a rename here silently un-hides every folder the user
|
||||
/// chose, with no setting having been changed.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_round_trip_through_json() {
|
||||
let settings = LibrarySettings {
|
||||
excluded_item_ids: vec!["folder-1".to_string(), "folder-2".to_string()],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).expect("serialises");
|
||||
assert!(
|
||||
json.contains("\"excludedItemIds\""),
|
||||
"camelCase on the wire"
|
||||
);
|
||||
|
||||
let parsed: LibrarySettings = serde_json::from_str(&json).expect("parses back");
|
||||
assert_eq!(parsed, settings);
|
||||
}
|
||||
|
||||
/// Settings persisted before this feature existed — and a row with the key
|
||||
/// missing entirely — must load as "nothing hidden", never as an error the
|
||||
/// caller has to handle or a default that hides something.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_default_hides_nothing() {
|
||||
let parsed: LibrarySettings = serde_json::from_str("{}").expect("parses");
|
||||
assert!(parsed.excluded_item_ids.is_empty());
|
||||
assert!(LibrarySettings::default().excluded_item_ids.is_empty());
|
||||
}
|
||||
|
||||
/// Blank and duplicate ids are dropped on the way in, so a half-written or
|
||||
/// hand-edited value cannot grow the list without bound or store an id that
|
||||
/// matches nothing yet still shows as a selection.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_library_settings_sanitised() {
|
||||
let settings = LibrarySettings {
|
||||
excluded_item_ids: vec![
|
||||
" folder-1 ".to_string(),
|
||||
"".to_string(),
|
||||
" ".to_string(),
|
||||
"folder-1".to_string(),
|
||||
"folder-2".to_string(),
|
||||
],
|
||||
}
|
||||
.sanitised();
|
||||
|
||||
assert_eq!(
|
||||
settings.excluded_item_ids,
|
||||
vec!["folder-1".to_string(), "folder-2".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod conversions;
|
||||
pub mod device;
|
||||
pub mod download;
|
||||
pub mod favorites;
|
||||
pub mod library;
|
||||
pub mod offline;
|
||||
pub mod playback_mode;
|
||||
pub mod playback_reporting;
|
||||
@@ -25,6 +26,7 @@ pub use connectivity::*;
|
||||
pub use conversions::*;
|
||||
pub use device::*;
|
||||
pub use download::*;
|
||||
pub use library::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
|
||||
@@ -1705,6 +1705,23 @@ pub async fn player_set_subtitle_track(
|
||||
Ok(get_player_status(&controller))
|
||||
}
|
||||
|
||||
/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
|
||||
/// works in.
|
||||
///
|
||||
/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
|
||||
/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
|
||||
/// comparison downstream, so a backend clamp cannot catch it either. It is
|
||||
/// treated as "no volume asked for" and floored to 0.0.
|
||||
///
|
||||
/// TRACES: DR-212 | UT-206
|
||||
fn normalize_volume(volume: f32) -> f32 {
|
||||
if volume.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
volume.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_set_volume(
|
||||
@@ -1712,6 +1729,12 @@ pub async fn player_set_volume(
|
||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||
volume: f32,
|
||||
) -> Result<PlayerStatus, String> {
|
||||
// Clamp at the boundary as well as in each backend: the remote branch below
|
||||
// never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
|
||||
// hand the server i32::MAX as a volume percentage.
|
||||
// TRACES: DR-212 | UT-206
|
||||
let volume = normalize_volume(volume);
|
||||
|
||||
// Check if we're in remote mode
|
||||
let mode = playback_mode.0.get_mode();
|
||||
|
||||
@@ -2769,6 +2792,44 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
mod tests {
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// UT-206 — the volume the command hands on is always a real number in
|
||||
/// 0.0..=1.0.
|
||||
///
|
||||
/// Every backend clamps for itself, but the remote branch of
|
||||
/// `player_set_volume` reaches no backend at all: it does
|
||||
/// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
|
||||
/// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
|
||||
/// not enough — it has to be tested for.
|
||||
///
|
||||
/// TRACES: DR-212 | UT-206
|
||||
#[test]
|
||||
fn test_normalize_volume_clamps_and_rejects_nan() {
|
||||
use super::normalize_volume;
|
||||
|
||||
// In-range values pass through untouched.
|
||||
assert_eq!(normalize_volume(0.0), 0.0);
|
||||
assert_eq!(normalize_volume(0.5), 0.5);
|
||||
assert_eq!(normalize_volume(1.0), 1.0);
|
||||
|
||||
// Out of range clamps to the same 0.0..=1.0 the backends use.
|
||||
assert_eq!(normalize_volume(-0.5), 0.0);
|
||||
assert_eq!(normalize_volume(42.0), 1.0);
|
||||
assert_eq!(normalize_volume(f32::INFINITY), 1.0);
|
||||
assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
|
||||
|
||||
// NaN is not a volume; it must not reach the Jellyfin percentage
|
||||
// conversion or a backend.
|
||||
let from_nan = normalize_volume(f32::NAN);
|
||||
assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
|
||||
assert_eq!(from_nan, 0.0);
|
||||
|
||||
// Whatever comes out survives the remote branch's percentage cast.
|
||||
for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
|
||||
let percent = (normalize_volume(input) * 100.0) as i32;
|
||||
assert!((0..=100).contains(&percent), "input {input} gave {percent}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||
/// up on the `MediaItem` the native backend loads.
|
||||
///
|
||||
|
||||
@@ -79,6 +79,10 @@ use commands::{
|
||||
get_smart_cache_stats,
|
||||
image_get_url,
|
||||
is_item_pinned,
|
||||
// Library browsing preferences (hidden folders)
|
||||
library_get_exclusion_candidates,
|
||||
library_get_settings,
|
||||
library_set_settings,
|
||||
lms_create_sync_group,
|
||||
lms_dissolve_sync_group,
|
||||
// LMS multi-room sync group commands
|
||||
@@ -884,6 +888,10 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
sync_full_catalog,
|
||||
catalog_sync_status,
|
||||
set_show_server_catalog,
|
||||
// Library browsing preferences (UR-076 / DR-209)
|
||||
library_get_settings,
|
||||
library_set_settings,
|
||||
library_get_exclusion_candidates,
|
||||
resume_queued_downloads,
|
||||
get_download_manager_stats,
|
||||
set_max_concurrent_downloads,
|
||||
@@ -1312,6 +1320,18 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
// Restore the folders the user hid from browsing, for the same
|
||||
// reason and in the same way. Until it lands nothing is hidden —
|
||||
// the pre-existing behaviour — and no query can have run this early.
|
||||
//
|
||||
// TRACES: UR-076 | DR-209
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::commands::restore_library_settings(&handle).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Library folders the user has chosen to keep out of browsing.
|
||||
//!
|
||||
//! Some people file things inside a library that they never want to see while
|
||||
//! browsing it — a "Podcasts" folder sitting in the music library is the
|
||||
//! canonical case: its albums and tracks leak into album, artist, track and
|
||||
//! playlist listings even though the user thinks of them as a different medium.
|
||||
//!
|
||||
//! This is a *domain* rule, not a presentation one: what an item belongs to, and
|
||||
//! therefore whether a query should return it, is decided here in the repository
|
||||
//! layer so every query path agrees. The predecessor of this module was a
|
||||
//! frontend filter that dropped anything literally named "Podcasts" — one user's
|
||||
//! folder layout, keyed on an English string, shipped to everyone. Excluding by
|
||||
//! **id** instead of name is what makes the setting survive a rename, a
|
||||
//! translation, or two folders sharing a name.
|
||||
//!
|
||||
//! The excluded set is process-wide rather than a field on a repository for the
|
||||
//! same reason as `online::STREAMING_QUALITY`: it is a preference about *this
|
||||
//! user's browsing*, not about a server session, so it must survive a repository
|
||||
//! being rebuilt on re-login. It is written by the settings command and restored
|
||||
//! from the database at startup.
|
||||
//!
|
||||
//! TRACES: UR-076 | DR-209
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::types::{MediaItem, SearchResult};
|
||||
use crate::utils::lock::RwLockSafe;
|
||||
|
||||
/// Ids (normalised — see [`normalise_id`]) of items the user has hidden.
|
||||
///
|
||||
/// Empty by default: nobody inherits somebody else's folder layout.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
static EXCLUDED_IDS: RwLock<Vec<String>> = RwLock::new(Vec::new());
|
||||
|
||||
/// Jellyfin writes the same GUID both dashed and undashed depending on the
|
||||
/// endpoint, and ids arriving over IPC may carry stray whitespace. Comparing a
|
||||
/// canonical form means a stored id keeps matching whichever spelling a query
|
||||
/// happens to return.
|
||||
fn normalise_id(id: &str) -> String {
|
||||
id.trim().replace('-', "").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Replace the excluded set. Ids are normalised, de-duplicated and blanks
|
||||
/// dropped, so a malformed value can never hide more than it names.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn set_excluded_item_ids(ids: &[String]) {
|
||||
let mut normalised: Vec<String> = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let id = normalise_id(id);
|
||||
if id.is_empty() || normalised.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
normalised.push(id);
|
||||
}
|
||||
*EXCLUDED_IDS.write_safe() = normalised;
|
||||
}
|
||||
|
||||
/// The excluded set as currently applied, normalised.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn excluded_item_ids() -> Vec<String> {
|
||||
EXCLUDED_IDS.read_safe().clone()
|
||||
}
|
||||
|
||||
/// Snapshot of the excluded set, taken once per list so a long listing does not
|
||||
/// re-lock per item.
|
||||
fn excluded_snapshot() -> HashSet<String> {
|
||||
EXCLUDED_IDS.read_safe().iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Whether `item` falls under one of `excluded`.
|
||||
///
|
||||
/// The set is passed in rather than read from the global so the rule itself is a
|
||||
/// pure function and can be tested without touching process state.
|
||||
///
|
||||
/// An item matches on its own id or on any of the *links* it carries back to a
|
||||
/// container: parent, album, library, series or season, and its artist entries.
|
||||
/// That covers the shapes a hidden folder actually reaches a listing in — the
|
||||
/// folder itself in a container listing, its albums (whose `parent_id` is the
|
||||
/// folder), and their tracks (whose `album_id` is the album). It is deliberately
|
||||
/// link-based rather than a full ancestry walk: the repository has no ancestor
|
||||
/// index, and walking one would cost a round trip per row.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn is_excluded_by(excluded: &HashSet<String>, item: &MediaItem) -> bool {
|
||||
if excluded.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hidden(excluded: &HashSet<String>, id: &str) -> bool {
|
||||
excluded.contains(&normalise_id(id))
|
||||
}
|
||||
|
||||
fn hidden_opt(excluded: &HashSet<String>, id: &Option<String>) -> bool {
|
||||
match id {
|
||||
Some(id) => hidden(excluded, id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
hidden(excluded, &item.id)
|
||||
|| hidden_opt(excluded, &item.parent_id)
|
||||
|| hidden_opt(excluded, &item.album_id)
|
||||
|| hidden_opt(excluded, &item.library_id)
|
||||
|| hidden_opt(excluded, &item.series_id)
|
||||
|| hidden_opt(excluded, &item.season_id)
|
||||
|| match &item.artist_items {
|
||||
Some(artists) => artists.iter().any(|a| hidden(excluded, &a.id)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the user's hidden items from a repository result.
|
||||
///
|
||||
/// Implemented as a trait so the hybrid repository's generic result helpers —
|
||||
/// where the cache and server legs of every cache-first race converge — can
|
||||
/// apply it to whatever they are carrying, instead of each query having to
|
||||
/// remember to.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub trait ExcludeHidden: Sized {
|
||||
fn without_excluded(self) -> Self;
|
||||
}
|
||||
|
||||
impl ExcludeHidden for Vec<MediaItem> {
|
||||
fn without_excluded(mut self) -> Self {
|
||||
let excluded = excluded_snapshot();
|
||||
if excluded.is_empty() {
|
||||
return self;
|
||||
}
|
||||
self.retain(|item| !is_excluded_by(&excluded, item));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ExcludeHidden for SearchResult {
|
||||
fn without_excluded(mut self) -> Self {
|
||||
let before = self.items.len();
|
||||
self.items = self.items.without_excluded();
|
||||
// `total_record_count` is what the UI shows as "N results" and what
|
||||
// paging is built against; leaving the server's count would advertise
|
||||
// rows that were just removed.
|
||||
let removed = before.saturating_sub(self.items.len());
|
||||
self.total_record_count = self.total_record_count.saturating_sub(removed);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ExcludeHidden for MediaItem {
|
||||
/// A single item fetched by id is never hidden.
|
||||
///
|
||||
/// Exclusion hides things from *browsing*. An item asked for by id was
|
||||
/// navigated to deliberately, or is being resolved by the player or a
|
||||
/// download — answering "not found" there would break playback of anything
|
||||
/// inside a hidden folder rather than merely tidying a listing.
|
||||
fn without_excluded(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repository::types::ArtistItem;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Serialises the tests that write the process-global excluded set. Cargo
|
||||
/// runs a crate's tests in one process, so without this two of them racing
|
||||
/// would see each other's ids.
|
||||
static EXCLUSION_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn item(id: &str) -> MediaItem {
|
||||
MediaItem {
|
||||
id: id.to_string(),
|
||||
name: format!("item {id}"),
|
||||
..MediaItem::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn excluded(ids: &[&str]) -> HashSet<String> {
|
||||
ids.iter().map(|id| normalise_id(id)).collect()
|
||||
}
|
||||
|
||||
/// The default is empty: nobody inherits another user's folder layout, which
|
||||
/// is exactly what the hardcoded "Podcasts" name filter did.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_no_exclusions_by_default_keeps_everything() {
|
||||
let empty = HashSet::new();
|
||||
assert!(!is_excluded_by(&empty, &item("anything")));
|
||||
|
||||
let items = vec![item("a"), item("b")];
|
||||
assert_eq!(items.without_excluded().len(), 2);
|
||||
}
|
||||
|
||||
/// The folder itself, and anything linking back to it, is hidden.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_excludes_the_folder_and_what_points_at_it() {
|
||||
let set = excluded(&["folder-1"]);
|
||||
|
||||
assert!(is_excluded_by(&set, &item("folder-1")), "the folder itself");
|
||||
|
||||
let album = MediaItem {
|
||||
parent_id: Some("folder-1".to_string()),
|
||||
..item("album-1")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &album), "an album inside the folder");
|
||||
|
||||
let track = MediaItem {
|
||||
album_id: Some("folder-1".to_string()),
|
||||
..item("track-1")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &track), "a track of the folder");
|
||||
|
||||
let elsewhere = MediaItem {
|
||||
parent_id: Some("folder-2".to_string()),
|
||||
..item("album-2")
|
||||
};
|
||||
assert!(!is_excluded_by(&set, &elsewhere), "an unrelated album");
|
||||
}
|
||||
|
||||
/// A whole library, a series/season and an artist are all excludable by the
|
||||
/// same check — the setting is "hide this container", not "hide albums".
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_excludes_via_every_container_link() {
|
||||
let set = excluded(&["container"]);
|
||||
|
||||
let by_library = MediaItem {
|
||||
library_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_library));
|
||||
|
||||
let by_series = MediaItem {
|
||||
series_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_series));
|
||||
|
||||
let by_season = MediaItem {
|
||||
season_id: Some("container".to_string()),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_season));
|
||||
|
||||
let by_artist = MediaItem {
|
||||
artist_items: Some(vec![
|
||||
ArtistItem {
|
||||
id: "other".to_string(),
|
||||
name: "Other".to_string(),
|
||||
},
|
||||
ArtistItem {
|
||||
id: "container".to_string(),
|
||||
name: "Hidden".to_string(),
|
||||
},
|
||||
]),
|
||||
..item("x")
|
||||
};
|
||||
assert!(is_excluded_by(&set, &by_artist));
|
||||
}
|
||||
|
||||
/// Ids are matched by identity, not spelling: Jellyfin serves the same GUID
|
||||
/// dashed on one endpoint and undashed on another, and a stored id that
|
||||
/// stopped matching would silently un-hide the folder.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_id_matching_ignores_dashes_case_and_padding() {
|
||||
let set = excluded(&[" A1B2C3D4-0000-0000-0000-000000000000 "]);
|
||||
assert!(is_excluded_by(
|
||||
&set,
|
||||
&item("a1b2c3d4-0000-0000-0000-000000000000")
|
||||
));
|
||||
assert!(is_excluded_by(
|
||||
&set,
|
||||
&item("A1B2C3D4000000000000000000000000")
|
||||
));
|
||||
assert!(!is_excluded_by(&set, &item("a1b2c3d4-0000-0000-0000-1")));
|
||||
}
|
||||
|
||||
/// Filtering a `SearchResult` must also correct its count — the listing
|
||||
/// header reads it, and a stale total advertises rows that are not there.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_search_result_count_follows_the_filter() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&["hidden".to_string()]);
|
||||
|
||||
let result = SearchResult {
|
||||
items: vec![item("keep"), item("hidden"), item("keep-2")],
|
||||
total_record_count: 3,
|
||||
}
|
||||
.without_excluded();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.total_record_count, 2);
|
||||
assert!(result.items.iter().all(|i| i.id != "hidden"));
|
||||
}
|
||||
|
||||
/// A single item asked for by id is never withheld: exclusion hides things
|
||||
/// from browsing, and refusing it here would break playback and downloads of
|
||||
/// anything inside a hidden folder.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_direct_item_lookup_is_never_hidden() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&["hidden".to_string()]);
|
||||
|
||||
let still_matches = is_excluded_by(&excluded_snapshot(), &item("hidden"));
|
||||
let survives = item("hidden").without_excluded();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
|
||||
assert!(still_matches, "the predicate still matches the item");
|
||||
assert_eq!(survives.id, "hidden", "but a direct lookup keeps it");
|
||||
}
|
||||
|
||||
/// The stored set is sanitised on the way in: blanks dropped, duplicates
|
||||
/// collapsed, spellings normalised.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209 | UT-203
|
||||
#[test]
|
||||
fn test_set_excluded_item_ids_sanitises() {
|
||||
let _guard = EXCLUSION_TEST_LOCK.lock_safe();
|
||||
set_excluded_item_ids(&[
|
||||
" ".to_string(),
|
||||
"AB-CD".to_string(),
|
||||
"abcd".to_string(),
|
||||
"ef".to_string(),
|
||||
]);
|
||||
let stored = excluded_item_ids();
|
||||
|
||||
set_excluded_item_ids(&[]);
|
||||
let cleared = excluded_item_ids();
|
||||
|
||||
assert_eq!(stored, vec!["abcd".to_string(), "ef".to_string()]);
|
||||
assert!(cleared.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use async_trait::async_trait;
|
||||
use log::{debug, warn};
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
use super::exclusions::ExcludeHidden;
|
||||
use super::{types::*, MediaRepository, OfflineRepository, OnlineRepository};
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
@@ -151,6 +152,27 @@ impl HybridRepository {
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Immediate children of a container with the user's browsing exclusions
|
||||
/// **not** applied.
|
||||
///
|
||||
/// Exists for the exclusion picker in settings. Everything else in this
|
||||
/// repository hides what the user has hidden, which would make the setting
|
||||
/// one-way: a folder already excluded would vanish from the list of folders
|
||||
/// to exclude and could never be un-hidden. Server-first so the picker sees
|
||||
/// the real library, falling back to the cache when unreachable.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub async fn get_items_unfiltered(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
match self.online.get_items(parent_id, options.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(e) => self.offline.get_items(parent_id, options).await.or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search only the local SQLite cache (downloaded content).
|
||||
///
|
||||
/// Fast (100ms timeout) — used to render instant results before the server
|
||||
@@ -165,6 +187,7 @@ impl HybridRepository {
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites held locally, without touching the server. Backs the instant
|
||||
@@ -179,6 +202,7 @@ impl HybridRepository {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Favourites straight from the server, persisted to the cache on the way
|
||||
@@ -199,7 +223,7 @@ impl HybridRepository {
|
||||
debug!("[HybridRepo] Failed to cache favourites: {:?}", e);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
Ok(result.without_excluded())
|
||||
}
|
||||
|
||||
/// Fetch a folder's items from the live server and persist them to the
|
||||
@@ -235,6 +259,10 @@ impl HybridRepository {
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
// Deliberately *not* filtered by the user's hidden folders: this surface
|
||||
// manages what is on the device, and hiding a download would leave the
|
||||
// user unable to delete a file they can still see the disk usage of.
|
||||
// TRACES: UR-076 | DR-209
|
||||
self.offline.get_downloaded_items(parent_id, options).await
|
||||
}
|
||||
|
||||
@@ -258,7 +286,10 @@ impl HybridRepository {
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.online.search(query, options).await
|
||||
self.online
|
||||
.search(query, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
/// Merge cache and server search results into a single de-duplicated list.
|
||||
@@ -318,20 +349,30 @@ impl HybridRepository {
|
||||
/// 3. If cache is empty/stale → query server (fresh data)
|
||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||
///
|
||||
/// Both legs are passed through [`ExcludeHidden`] before the "does the cache
|
||||
/// have content?" question is asked. This is the single place the cache and
|
||||
/// server results of a cache-first query converge, so applying the user's
|
||||
/// browsing exclusions here covers every query built on it at once — and
|
||||
/// filtering *before* the content check is what makes a cache page holding
|
||||
/// nothing but hidden items fall through to the server instead of being
|
||||
/// served as an empty listing.
|
||||
///
|
||||
/// @req: UR-002 - Access media when online or offline
|
||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||
///
|
||||
/// TRACES: UR-002, UR-076 | DR-013, DR-209
|
||||
async fn parallel_race<T, F1, F2>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
server_future: F2,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
{
|
||||
// Try cache first (100ms timeout already applied by callers)
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -343,7 +384,7 @@ impl HybridRepository {
|
||||
// Cache miss — fall back to server
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => {
|
||||
// Server failed, try to return cache even if empty
|
||||
cache_result.or(Err(e))
|
||||
@@ -364,7 +405,7 @@ impl HybridRepository {
|
||||
/// The callback runs only on a cache hit — on a miss the server result is
|
||||
/// already being fetched and cached by the normal path.
|
||||
///
|
||||
/// TRACES: UR-002, UR-025 | DR-155
|
||||
/// TRACES: UR-002, UR-025, UR-076 | DR-155, DR-209
|
||||
async fn race_with_refresh<T, F1, F2, R>(
|
||||
&self,
|
||||
cache_future: F1,
|
||||
@@ -372,12 +413,12 @@ impl HybridRepository {
|
||||
on_cache_hit: R,
|
||||
) -> Result<T, RepoError>
|
||||
where
|
||||
T: MeaningfulContent + Clone + Send + 'static,
|
||||
T: MeaningfulContent + ExcludeHidden + Clone + Send + 'static,
|
||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
R: FnOnce(),
|
||||
{
|
||||
let cache_result = cache_future.await;
|
||||
let cache_result = cache_future.await.map(ExcludeHidden::without_excluded);
|
||||
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -389,7 +430,7 @@ impl HybridRepository {
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data),
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
}
|
||||
}
|
||||
@@ -475,10 +516,18 @@ impl MediaRepository for HybridRepository {
|
||||
let server_handle =
|
||||
tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
|
||||
|
||||
// Check cache first (fast, 100ms timeout)
|
||||
// Check cache first (fast, 100ms timeout).
|
||||
//
|
||||
// Exclusions are applied here rather than at each return below so the
|
||||
// "has content" decisions further down are made about what the user will
|
||||
// actually see. `get_items` is the one query that does not go through
|
||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||
// background cache write — so it applies the filter itself.
|
||||
// TRACES: UR-076 | DR-209
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await;
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
@@ -555,7 +604,12 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(server_data)
|
||||
// The cache keeps the server's full page (above) — an exclusion
|
||||
// is a view preference and can be undone, so hiding items from
|
||||
// the *cache* would make un-hiding them require a re-crawl. Only
|
||||
// what is handed back is filtered.
|
||||
// TRACES: UR-076 | DR-209
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
@@ -667,7 +721,10 @@ impl MediaRepository for HybridRepository {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online.get_next_up_episodes(series_id, limit).await
|
||||
self.online
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
@@ -831,12 +888,18 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV requires server communication - delegate to online repository
|
||||
self.online.get_live_tv_channels().await
|
||||
self.online
|
||||
.get_live_tv_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
||||
// Plugin channels require server communication - delegate to online repository
|
||||
self.online.get_channels().await
|
||||
self.online
|
||||
.get_channels()
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
|
||||
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod device_profile;
|
||||
/// User-chosen browsing exclusions (UR-076 / DR-209).
|
||||
pub mod exclusions;
|
||||
pub mod hybrid;
|
||||
pub mod offline;
|
||||
pub mod online;
|
||||
|
||||
@@ -1128,7 +1128,7 @@ impl OfflineRepository {
|
||||
let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
|
||||
|
||||
let mut sizes = std::collections::HashMap::new();
|
||||
for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) {
|
||||
for (id, bytes) in leaves.into_iter().chain(containers) {
|
||||
// A container id can never collide with a leaf id, so a plain insert
|
||||
// is fine; use entry to be defensive against duplicate rows.
|
||||
*sizes.entry(id).or_insert(0) += bytes;
|
||||
@@ -1245,20 +1245,22 @@ impl MediaRepository for OfflineRepository {
|
||||
_ => "i.sort_name ASC, i.name ASC",
|
||||
};
|
||||
|
||||
// Build type filter for optional filtering
|
||||
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
||||
if !include_item_types.is_empty() {
|
||||
let types = include_item_types
|
||||
.iter()
|
||||
.map(|t| format!("'{}'", t))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(" AND i.item_type IN ({})", types)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} 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 {
|
||||
let placeholders = vec!["?"; type_values.len()].join(",");
|
||||
format!(" AND i.item_type IN ({})", placeholders)
|
||||
};
|
||||
|
||||
// Favourites narrowing for a normal library listing. Bound rather than
|
||||
@@ -1350,6 +1352,11 @@ impl MediaRepository for OfflineRepository {
|
||||
QueryParam::String(parent_id.to_string()), // i.series_id = ?
|
||||
QueryParam::String(parent_id.to_string()), // libraries.id = ?
|
||||
];
|
||||
// Positional order matters: the type placeholders sit in `{type_filter}`,
|
||||
// which the statement interpolates immediately after the parent-matching
|
||||
// group and before `{favorites_filter}`, so they bind here — after the
|
||||
// six ids above, before the favourites user id.
|
||||
params.extend(type_values.iter().cloned().map(QueryParam::String));
|
||||
if !favorites_filter.is_empty() {
|
||||
params.push(QueryParam::String(self.user_id.clone())); // ud.user_id = ?
|
||||
}
|
||||
@@ -4481,6 +4488,113 @@ mod tests {
|
||||
assert_eq!(ids, vec!["movie-fav"]);
|
||||
}
|
||||
|
||||
/// UT-206 — `include_item_types` reaches the listing query as bound
|
||||
/// parameters, so a type name can only ever be compared as data.
|
||||
///
|
||||
/// Interpolated, the type below closed the `IN (` list and commented out the
|
||||
/// rest of the line, leaving `... AND i.item_type IN ('Movie') OR 1=1`, which
|
||||
/// is true for every row — the listing then returned the whole cache
|
||||
/// regardless of parent or type. Bound, it is just a type name that matches
|
||||
/// nothing.
|
||||
///
|
||||
/// TRACES: UR-065 | DR-212 | UT-206
|
||||
#[tokio::test]
|
||||
async fn test_get_items_type_filter_is_bound_not_interpolated() {
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db_service = create_test_db();
|
||||
seed_favorites(&db_service).await;
|
||||
let repo = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let injected = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie') OR 1=1 --".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("a hostile type name must be data, not a broken query");
|
||||
assert!(
|
||||
injected.items.is_empty(),
|
||||
"no cached item has that type, so nothing may come back; got {:?}",
|
||||
injected
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// A quote on its own is likewise just a character in a type name.
|
||||
let quoted = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Mo'vie".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("an embedded quote must not break the query");
|
||||
assert!(quoted.items.is_empty());
|
||||
}
|
||||
|
||||
/// UT-206 — binding the type filter must not disturb the positions of the
|
||||
/// parameters around it: the parent ids bind before it and the favourites
|
||||
/// user id after it. A misordered vec would silently compare `user_id`
|
||||
/// against `item_type`, so this asserts the filters still compose.
|
||||
///
|
||||
/// TRACES: UR-065, UR-067 | DR-212 | UT-206
|
||||
#[tokio::test]
|
||||
async fn test_get_items_binds_multiple_types_in_parameter_order() {
|
||||
let _guard = lock_catalog_browse();
|
||||
set_include_catalog_browse(true);
|
||||
|
||||
let db_service = create_test_db();
|
||||
seed_favorites(&db_service).await;
|
||||
let repo = OfflineRepository::new(
|
||||
db_service,
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let both = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = both.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav", "movie-plain"]);
|
||||
|
||||
// Two type placeholders *and* the favourites parameter after them.
|
||||
let favourites = repo
|
||||
.get_items(
|
||||
"lib-1",
|
||||
Some(GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie".to_string(), "MusicAlbum".to_string()]),
|
||||
favorites_only: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ids: Vec<&str> = favourites.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["album-fav", "movie-fav"]);
|
||||
}
|
||||
|
||||
/// UT-102 — caching a server result mirrors its favourite state locally,
|
||||
/// but never over a row still waiting to be pushed.
|
||||
///
|
||||
|
||||
@@ -235,7 +235,11 @@ impl OnlineRepository {
|
||||
item_id: &str,
|
||||
t: f64,
|
||||
) -> Result<Vec<JRayActor>, RepoError> {
|
||||
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
|
||||
let endpoint = format!(
|
||||
"/Plugins/JRay/Items/{}/jray?t={}",
|
||||
urlencoding::encode(item_id),
|
||||
t
|
||||
);
|
||||
match self.get_json::<JRayContext>(&endpoint).await {
|
||||
Ok(context) => Ok(context.actors),
|
||||
// No plugin / no truth data for this item — not an error to the user.
|
||||
@@ -802,7 +806,17 @@ fn build_get_items_endpoint(
|
||||
parent_id: &str,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
let mut endpoint = format!("/Users/{}/Items?ParentId={}", user_id, parent_id);
|
||||
// Every value below is percent-encoded before it goes into the query
|
||||
// string, the same way `Genres` and `SearchTerm` already are: these are
|
||||
// values, not URL syntax, so a space or an `&` in one must not split it
|
||||
// into another parameter.
|
||||
//
|
||||
// TRACES: UR-007 | DR-212 | UT-206
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?ParentId={}",
|
||||
user_id,
|
||||
urlencoding::encode(parent_id)
|
||||
);
|
||||
|
||||
if let Some(opts) = options {
|
||||
if let Some(limit) = opts.limit {
|
||||
@@ -812,13 +826,25 @@ fn build_get_items_endpoint(
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
if let Some(types) = &opts.include_item_types {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
// Encode each type, not the joined string: the comma is the
|
||||
// list separator Jellyfin splits on.
|
||||
let encoded: Vec<String> = types
|
||||
.iter()
|
||||
.map(|t| urlencoding::encode(t).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_by) = &opts.sort_by {
|
||||
endpoint.push_str(&format!("&SortBy={}", sort_by));
|
||||
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
|
||||
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
|
||||
let encoded: Vec<String> = sort_by
|
||||
.split(',')
|
||||
.map(|field| urlencoding::encode(field).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_order) = &opts.sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", sort_order));
|
||||
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
|
||||
}
|
||||
if let Some(recursive) = opts.recursive {
|
||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||
@@ -1147,7 +1173,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
|
||||
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
let media_item = item.into_media_item(self.user_id.clone());
|
||||
@@ -1510,7 +1536,7 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
@@ -1939,7 +1965,7 @@ impl MediaRepository for OnlineRepository {
|
||||
live_stream_id: Option<String>,
|
||||
}
|
||||
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
let request = OpenLiveStreamRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
@@ -2214,7 +2240,11 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -2244,7 +2274,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2286,7 +2320,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2327,7 +2365,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2372,7 +2414,11 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(person_id)
|
||||
);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.into_media_item(self.user_id.clone()))
|
||||
}
|
||||
@@ -2461,7 +2507,7 @@ impl MediaRepository for OnlineRepository {
|
||||
|
||||
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self
|
||||
@@ -2496,7 +2542,7 @@ impl MediaRepository for OnlineRepository {
|
||||
"[OnlineRepo] Renaming playlist {} to '{}'",
|
||||
playlist_id, name
|
||||
);
|
||||
let endpoint = format!("/Items/{}", playlist_id);
|
||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
||||
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
|
||||
.await
|
||||
}
|
||||
@@ -2534,8 +2580,17 @@ impl MediaRepository for OnlineRepository {
|
||||
item_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = item_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
|
||||
// Encode each id, not the joined string: the comma separates the list.
|
||||
let ids_param = item_ids
|
||||
.iter()
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?Ids={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -2549,8 +2604,16 @@ impl MediaRepository for OnlineRepository {
|
||||
entry_ids.len(),
|
||||
playlist_id
|
||||
);
|
||||
let ids_param = entry_ids.join(",");
|
||||
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
|
||||
let ids_param = entry_ids
|
||||
.iter()
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?EntryIds={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self
|
||||
@@ -3529,6 +3592,73 @@ mod tests {
|
||||
assert!(!off.contains("Filters=IsFavorite"));
|
||||
}
|
||||
|
||||
/// UT-206 — the values this endpoint builder puts in the query string are
|
||||
/// percent-encoded, like `Genres` and `SearchTerm` already are.
|
||||
///
|
||||
/// Unencoded, a value carrying `&` or `=` splits into an extra query
|
||||
/// parameter (a parent id containing a space produced a malformed URL
|
||||
/// outright), so the request the server sees is not the one that was built.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-212 | UT-206
|
||||
#[test]
|
||||
fn test_get_items_endpoint_encodes_query_values() {
|
||||
let endpoint = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib 1&Filters=IsFavorite",
|
||||
Some(&GetItemsOptions {
|
||||
include_item_types: Some(vec!["Movie&x=1".to_string()]),
|
||||
sort_by: Some("Sort Name".to_string()),
|
||||
sort_order: Some("Ascending&y=2".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("ParentId=lib%201%26Filters%3DIsFavorite"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&IncludeItemTypes=Movie%26x%3D1"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(endpoint.contains("&SortBy=Sort%20Name"), "{endpoint}");
|
||||
assert!(
|
||||
endpoint.contains("&SortOrder=Ascending%26y%3D2"),
|
||||
"{endpoint}"
|
||||
);
|
||||
// Nothing smuggled in as a parameter of its own.
|
||||
assert!(!endpoint.contains("&Filters=IsFavorite"), "{endpoint}");
|
||||
assert!(!endpoint.contains("&x=1"), "{endpoint}");
|
||||
assert!(!endpoint.contains("&y=2"), "{endpoint}");
|
||||
}
|
||||
|
||||
/// The separators inside a list parameter must survive encoding: Jellyfin
|
||||
/// splits `SortBy` and `IncludeItemTypes` on commas, and `hybrid.rs` sends
|
||||
/// "ParentIndexNumber,IndexNumber,SortName" to order episodes.
|
||||
///
|
||||
/// TRACES: UR-007 | DR-212 | UT-206
|
||||
#[test]
|
||||
fn test_get_items_endpoint_keeps_list_separators() {
|
||||
let endpoint = build_get_items_endpoint(
|
||||
"u1",
|
||||
"lib-1",
|
||||
Some(&GetItemsOptions {
|
||||
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
|
||||
include_item_types: Some(vec!["Movie".to_string(), "Series".to_string()]),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&SortBy=ParentIndexNumber,IndexNumber,SortName"),
|
||||
"{endpoint}"
|
||||
);
|
||||
assert!(
|
||||
endpoint.contains("&IncludeItemTypes=Movie,Series"),
|
||||
"{endpoint}"
|
||||
);
|
||||
// A plain GUID parent id is unchanged by encoding.
|
||||
assert!(endpoint.contains("ParentId=lib-1"), "{endpoint}");
|
||||
}
|
||||
|
||||
/// A newly-added album must arrive as one entry, not one per track.
|
||||
///
|
||||
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
|
||||
|
||||
@@ -320,6 +320,52 @@ impl VideoSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Library browsing preferences.
|
||||
///
|
||||
/// Currently a single list: the folders (or whole libraries) the user has asked
|
||||
/// to keep out of browsing. It is a *list of ids*, never names — names are
|
||||
/// unstable, locale-dependent and non-unique, and the hardcoded name filter this
|
||||
/// setting replaced broke on exactly that. What the ids then hide is decided in
|
||||
/// `repository::exclusions`; this struct is only how the choice is carried and
|
||||
/// persisted.
|
||||
///
|
||||
/// The default is an empty list: nobody inherits another user's folder layout.
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
#[derive(specta::Type, Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibrarySettings {
|
||||
/// Stable item ids of the folders/libraries hidden from browsing.
|
||||
///
|
||||
/// `#[serde(default)]` so settings JSON persisted before this field existed
|
||||
/// loads as the previous behaviour (nothing hidden).
|
||||
#[serde(default)]
|
||||
pub excluded_item_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl LibrarySettings {
|
||||
/// Drop blanks and duplicates from the id list.
|
||||
///
|
||||
/// Applied on the way in from IPC and on the way out of the database, so a
|
||||
/// hand-edited or half-written value cannot make the list grow without bound
|
||||
/// or carry an empty id (which would match nothing but still be shown as a
|
||||
/// selection in the picker).
|
||||
///
|
||||
/// TRACES: UR-076 | DR-209
|
||||
pub fn sanitised(mut self) -> Self {
|
||||
let mut seen: Vec<String> = Vec::with_capacity(self.excluded_item_ids.len());
|
||||
for id in self.excluded_item_ids.drain(..) {
|
||||
let id = id.trim().to_string();
|
||||
if id.is_empty() || seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
seen.push(id);
|
||||
}
|
||||
self.excluded_item_ids = seen;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
|
||||
/// over JNI.
|
||||
///
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Thumbnail cache manager with LRU eviction
|
||||
|
||||
use log::error;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -28,6 +28,19 @@ impl Default for CacheConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make one part of a cache filename safe to put in a path.
|
||||
///
|
||||
/// Every part of the name comes from the caller — the item id and image type are
|
||||
/// taken verbatim from Jellyfin JSON — so none of them may contribute a path
|
||||
/// separator or a `..`. The rule is the one the image tag has always used
|
||||
/// (non-alphanumerics become `_`), applied to all three parts, so values that
|
||||
/// were already safe keep producing exactly the filename they did before.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
fn safe_component(value: &str) -> String {
|
||||
value.replace(|c: char| !c.is_alphanumeric(), "_")
|
||||
}
|
||||
|
||||
/// Thumbnail cache with LRU eviction
|
||||
pub struct ThumbnailCache {
|
||||
config: Arc<Mutex<CacheConfig>>,
|
||||
@@ -50,6 +63,34 @@ impl ThumbnailCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a cache filename against the cache directory, refusing anything
|
||||
/// that lands outside it.
|
||||
///
|
||||
/// `..` is folded away lexically rather than through `canonicalize`, so a
|
||||
/// file that does not exist yet still resolves — the same approach as
|
||||
/// `media_server::resolve_path`. `safe_component` should already have made an
|
||||
/// escape impossible; this is the check at the point of use.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
fn resolve_in_cache_dir(&self, filename: &str) -> Result<PathBuf, String> {
|
||||
let mut resolved = self.cache_dir.clone();
|
||||
for part in Path::new(filename).components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
resolved.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
other => resolved.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if resolved.starts_with(&self.cache_dir) {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err("Thumbnail path escapes the cache directory".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if caching is enabled
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.config.lock().map(|c| c.enabled).unwrap_or(true)
|
||||
@@ -155,6 +196,8 @@ impl ThumbnailCache {
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
// The arguments are the cache key (item/type/tag) plus the payload and its
|
||||
// dimensions — all independent scalars borrowed from the caller. A parameter
|
||||
// struct would only move the same list one level down.
|
||||
@@ -173,10 +216,16 @@ impl ThumbnailCache {
|
||||
return Err("Thumbnail caching is disabled".to_string());
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
let safe_tag = tag.replace(|c: char| !c.is_alphanumeric(), "_");
|
||||
let filename = format!("{}_{}_{}.jpg", item_id, image_type, safe_tag);
|
||||
let file_path = self.cache_dir.join(&filename);
|
||||
// Generate safe filename. The database keeps the *raw* key below, so the
|
||||
// lookup in `get_cached_path` still matches what the caller asks for; only
|
||||
// the on-disk name is sanitised, and the row records where it landed.
|
||||
let filename = format!(
|
||||
"{}_{}_{}.jpg",
|
||||
safe_component(item_id),
|
||||
safe_component(image_type),
|
||||
safe_component(tag)
|
||||
);
|
||||
let file_path = self.resolve_in_cache_dir(&filename)?;
|
||||
|
||||
// Ensure we have space (evict LRU items if needed)
|
||||
self.ensure_space(db.clone(), data.len() as u64).await?;
|
||||
@@ -571,4 +620,171 @@ mod tests {
|
||||
let size = cache.get_cache_size(conn.clone()).await;
|
||||
assert!(size <= 60);
|
||||
}
|
||||
|
||||
/// A traversal-style `item_id` must not steer a cache write out of the cache
|
||||
/// directory. The id reaches `save_thumbnail` verbatim from Jellyfin JSON, so
|
||||
/// it is not ours to trust.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_traversal_item_id() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let result = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"../evil",
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Where `../evil` lands if `..` is honoured: the cache dir's parent.
|
||||
let escaped = temp_dir.path().join("evil_Primary_tag1.jpg");
|
||||
assert!(
|
||||
!escaped.exists(),
|
||||
"wrote outside the cache directory: {}",
|
||||
escaped.display()
|
||||
);
|
||||
|
||||
// Refusing is acceptable; succeeding is too, as long as it stayed inside.
|
||||
if let Ok(path) = result {
|
||||
assert!(
|
||||
path.starts_with(&cache.cache_dir) && !path.to_string_lossy().contains(".."),
|
||||
"returned a path outside the cache directory: {}",
|
||||
path.display()
|
||||
);
|
||||
assert!(path.exists());
|
||||
}
|
||||
}
|
||||
|
||||
/// `Path::join` discards the base when handed an absolute path, so an
|
||||
/// absolute `item_id` would otherwise pick the write location outright.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_absolute_item_id() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let absolute_id = outside.path().join("evil").to_string_lossy().to_string();
|
||||
let result = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
&absolute_id,
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let escaped = outside.path().join("evil_Primary_tag1.jpg");
|
||||
assert!(
|
||||
!escaped.exists(),
|
||||
"wrote outside the cache directory: {}",
|
||||
escaped.display()
|
||||
);
|
||||
|
||||
if let Ok(path) = result {
|
||||
assert!(
|
||||
path.starts_with(&cache.cache_dir),
|
||||
"returned a path outside the cache directory: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `image_type` is equally unsanitised, and equally caller-supplied.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_confines_traversal_image_type() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let path = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"item1",
|
||||
"../Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("a malformed image_type should be sanitised, not break caching");
|
||||
|
||||
// The file belongs directly in the cache dir — no separator from the
|
||||
// image type may survive into the filename.
|
||||
assert_eq!(path.parent(), Some(cache.cache_dir.as_path()));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
/// Ids, types and tags that were already filesystem-safe — the overwhelming
|
||||
/// majority — keep producing exactly the filename they did before, so
|
||||
/// sanitising does not orphan existing cache entries.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_save_thumbnail_filename_unchanged_for_safe_values() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let path = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
"Primary",
|
||||
"abcdef0123456789",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
cache
|
||||
.cache_dir
|
||||
.join("a1b2c3d4e5f60718293a4b5c6d7e8f90_Primary_abcdef0123456789.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
/// Sanitising the filename must not desynchronise the write path from the
|
||||
/// read path: the database keeps the raw key and the resolved path, so a
|
||||
/// lookup after a save still finds the file that was written.
|
||||
///
|
||||
/// TRACES: | DR-210 | UT-204
|
||||
#[tokio::test]
|
||||
async fn test_traversal_item_id_still_round_trips() {
|
||||
let (conn, temp_dir) = setup_test_db();
|
||||
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
|
||||
|
||||
let saved = cache
|
||||
.save_thumbnail(
|
||||
conn.clone(),
|
||||
"../evil",
|
||||
"Primary",
|
||||
"tag1",
|
||||
b"fake image data",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let cached = cache
|
||||
.get_cached_path(conn.clone(), "../evil", "Primary", "tag1")
|
||||
.await;
|
||||
assert_eq!(cached, Some(saved));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.8.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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -248,6 +249,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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: [] };
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
commands.playerGetAudioSettings(),
|
||||
commands.playerGetVideoSettings(),
|
||||
getCacheConfig(),
|
||||
commands.playerGetEqPresets(),
|
||||
commands.playerGetStreamingQualities(),
|
||||
]);
|
||||
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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user