Compare commits

...
6 Commits
Author SHA1 Message Date
dtourolle d095e1f410 fix(ci): drop the bash-only shopt from the Linux artifact step
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m22s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 14m0s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m9s
Build & Release / Build Linux (push) Successful in 16m39s
Build & Release / Build Windows (push) Successful in 20m42s
Build & Release / Build Android (push) Successful in 29m18s
Build & Release / Create Release (push) Successful in 13s
"Prepare Linux artifacts" ran `shopt -s nullglob`, but the runner executes
`run:` blocks with POSIX sh, where shopt does not exist. It exited 127 and
failed the step -- so build-linux never uploaded, create-release (which
needs all three build jobs) never ran, and v0.9.0 and v0.9.1 both compiled
successfully but published nothing. The last release with assets is v0.8.2.

Reproduced under busybox sh: the current block prints "shopt: not found",
passes the unmatched rpm glob through literally ("cp: can't stat
'.../bundle/rpm/*.rpm'"), and exits 127. Without nullglob an unmatched
pattern stays literal, so test each candidate with [ -e ] instead; the
same input then exits 0 with the AppImage and deb copied.

traceability-check.yml already carries this rule in two places (`case`
instead of `[[ == ]]`, a pipe instead of a here-string). Keeping the fix
POSIX rather than adding `shell: bash` follows that convention and drops
the dependency on bash being present in the builder image.
2026-08-21 13:34:20 +02:00
dtourolle a7365b9511 fix(ci): cache the cargo registry, not the 16 GB target dir
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m10s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 13m59s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m13s
Build & Release / Build Linux (push) Failing after 16m29s
Build & Release / Build Android (push) Canceled after 0s
Build & Release / Create Release (push) Canceled after 0s
Build & Release / Build Windows (push) Canceled after 11m6s
The runner's 74 GB disk kept filling. Measured on the box: 24 GB of
act cache, 23.18 GB of it created in 20 days -- ~1.15 GB/day against a
30-day-unused / 90-day-used GC, so it could never converge.

Cause: src-tauri/target (16 GB locally: 9.6G debug, 3.2G release, 2.4G
android) was cached under five separate keys, all keyed on
hashFiles('**/Cargo.lock'). The release script stamps the version into
Cargo.lock, so all five invalidated on every chore(release) -- 32
distinct lockfile revisions in three months.

- Cache only registry/index, registry/cache and git/db. registry/src is
  omitted as well: cargo re-extracts it from the 155 MB of .crate
  tarballs rather than storing 1.1 GB extracted.
- Collapse the five per-job keys into one shared cargo-registry key.
  They existed to keep debug/release target artifacts from clobbering
  each other; with target uncached, registry contents are
  target-independent and every job wants the same crates.
- Split cargo-xwin into its own key. It tracks the xwin version in the
  builder image, not our lockfile, so keying it on Cargo.lock was
  re-downloading the whole Windows SDK on every release bump.
- CARGO_INCREMENTAL=0: never reused across runs, 3.5 GB of the debug dir.
- Installer artifact retention 30d -> 7d; tagged releases carry the
  binaries anyway.

Inflow drops from ~5 GB to ~150 MB per lockfile change. Tradeoff: Rust
jobs now compile cold every run (~31min vs ~9min on a cache hit for the
Linux release build). Most runs already paid that, since a release bump
invalidated every key. sccache with a hard size cap is the way back if
it bites.
2026-08-21 11:59:28 +02:00
dtourolle 16658889a2 fix(home): restart the hero banner timer on a manual change
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m31s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Failing after 14m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The rotation interval was installed once when the banner mounted and never
touched again, so a swipe, arrow or dot tap inherited whatever was left of the
running countdown — swiping 5.5s into a 6s interval moved the banner on half a
second later.

The timer moves into heroRotation.ts as a small restartable object so it can be
unit-tested, and every manual navigation path restarts it from that moment.
Verified red-first: with restart() reverted to leave a running timer alone, the
regression test fails.

Release 0.9.1.
2026-08-20 23:11:30 +02:00
dtourolle 98b2ede8bd fix(arch): build with custom-protocol so the package can load its own UI
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 21s
The PKGBUILD ran a bare `cargo build --release`. Without
`--features tauri/custom-protocol` Tauri does not embed the frontend and serves
it from devUrl instead, so the package compiled, linked, installed and passed
every check — then launched into "Could not connect to localhost: Connection
refused". `tauri build` passes that feature for you and the Android build passes
it explicitly; this path never did, so the Arch package has never worked.

Adds a check() that catches it at build time. It tests for the *assets*, not for
the dev URL: devUrl is part of the config blob generate_context!() embeds either
way, so grepping for it reports a failure on a correct build. A content-hashed
filename from the vite output can only appear if the bundle was embedded — which
is also why the fixed binary is ~400 KB larger.

Found by installing the package and launching it, which is the only thing that
would have found it.
2026-08-20 22:43:34 +02:00
dtourolle 38d56e6c89 fix(scripts): check links in tracked files, not everything on disk
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 19s
The prune list was wrong three times running: it walked the scratch worktrees
under .claude/, then makepkg's vendored cargo registry under
packaging/arch/src/, reporting a dependency's broken README as if it were ours.
Every one of those directories is already git-ignored, so asking git for the
file list makes the exclusion rule the same one the repo already maintains —
and it cannot drift the way a hand-kept prune list did.

It also makes the script do what its header always said it did: check tracked
markdown. Untracked-but-unignored files are included on purpose, so a new doc is
checked before it is committed rather than after. The find(1) path stays as a
fallback for a non-git checkout.

CI was unaffected — a fresh checkout has none of those directories — but the
local gate cried wolf, which is how a gate stops being read.
2026-08-20 22:27:29 +02:00
dtourolle 4f4741cee5 fix(release): stamp the Arch package version, and ship the licence with it
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 24s
pkgver sat at 0.0.18 while the tree was on 0.8.x, because the Arch package is
built by makepkg rather than the tauri bundler and set-version.sh never touched
it. makepkg produced a package whose version bore no relation to the source it
was built from — the exact failure that script exists to prevent, in the one
file it had missed.

Dev versions are converted to a pkgver Arch accepts: a hyphen separates pkgver
from pkgrel, so 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234. pkgrel resets to 1,
since a new upstream version restarts its packaging revisions.

Also installs LICENSE into /usr/share/licenses — MIT is not in Arch's common
licences, so a package under it has to carry the text.

Arch is not part of the automated release (build-release.yml covers linux,
windows and android), so v0.9.0 is unaffected; this applies to anyone building
the package by hand.
2026-08-20 22:26:35 +02:00
16 changed files with 396 additions and 66 deletions
+32 -10
View File
@@ -13,6 +13,10 @@ on:
- '**/*.md'
workflow_dispatch:
env:
# Incremental state is never reused between CI runs -- pure disk cost.
CARGO_INCREMENTAL: 0
jobs:
test:
name: Run Tests
@@ -33,13 +37,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -129,13 +142,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
+77 -35
View File
@@ -13,6 +13,8 @@ on:
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
# Incremental state is never reused between CI runs -- pure disk cost.
CARGO_INCREMENTAL: 0
jobs:
test:
@@ -27,13 +29,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-host-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -88,21 +99,25 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
# ⚠️ Key must NOT collide with the test job's `cargo-host` key: the test
# job runs first and saves debug/clippy artifacts under its key, and
# actions/cache skips saving on an exact-key hit — so a shared key meant
# this job's *release* artifacts were never cached and every Linux release
# build compiled cold (~31min vs ~9min for the correctly-keyed Windows job).
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-linux-release-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-linux-release-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -139,14 +154,19 @@ jobs:
#
# `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
#
# No `shopt -s nullglob` here: the runner executes `run:` blocks with
# POSIX sh, where shopt does not exist -- it exited 127 and killed the
# step (which is why v0.9.0 and v0.9.1 built but never published).
# Without nullglob an unmatched pattern stays literal, so test each
# candidate instead. Same POSIX-only rule as traceability-check.yml.
for bundle in \
src-tauri/target/release/bundle/appimage/*.AppImage \
src-tauri/target/release/bundle/deb/*.deb \
src-tauri/target/release/bundle/rpm/*.rpm; do
[ -e "$bundle" ] || continue
cp -v "$bundle" dist/linux/
done
shopt -u nullglob
# A release with no Linux package is a failure, not a quiet success.
if [ -z "$(ls -A dist/linux/)" ]; then
@@ -160,7 +180,7 @@ jobs:
with:
name: jellytau-linux
path: dist/linux/
retention-days: 30
retention-days: 7
build-windows:
name: Build Windows
@@ -178,14 +198,31 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
~/.cache/cargo-xwin
src-tauri/target
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-windows-
${{ runner.os }}-cargo-registry-
- name: Cache Windows CRT/SDK (cargo-xwin)
uses: actions/cache@v3
with:
path: ~/.cache/cargo-xwin
# Contents track the xwin version baked into the builder image, not our
# lockfile -- keying this on Cargo.lock re-downloaded the whole SDK on
# every release bump. Bump the suffix by hand if the image's xwin moves.
key: ${{ runner.os }}-cargo-xwin-v1
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -216,7 +253,7 @@ jobs:
with:
name: jellytau-windows
path: dist/windows/
retention-days: 30
retention-days: 7
build-android:
name: Build Android
@@ -235,17 +272,22 @@ jobs:
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
# Registry only -- never src-tauri/target. That directory is ~16 GB and
# was cached under five separate keys, which filled the runner's 74 GB
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
# registry/src is omitted too: cargo re-extracts it for free from
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
# `-release` suffix keeps this distinct from build-and-test.yml's
# android-check key, whose `cargo check` artifacts would otherwise
# claim the key first and block this job's release cache from ever
# being saved (same collision as the Linux job above).
key: ${{ runner.os }}-cargo-android-release-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
# One shared key across every job. The old per-job keys existed to stop
# debug/release target artifacts clobbering each other; with target no
# longer cached, registry contents are target-independent, so all jobs
# want the same crates. First job to finish saves; the rest restore.
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-release-
${{ runner.os }}-cargo-registry-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -309,7 +351,7 @@ jobs:
with:
name: jellytau-android
path: dist/android/
retention-days: 30
retention-days: 7
create-release:
name: Create Release
+14
View File
@@ -9,6 +9,20 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.9.1
A one-line fix to the home screen, released on its own because it is the kind of
small wrongness you notice every time.
### 🐛 Fixes
- **Swiping the hero banner now buys you a full six seconds.** The rotation
timer was started once when the banner appeared and then left alone, so a
swipe, arrow or dot tap inherited whatever was left of the running countdown
— swipe five and a half seconds in and the banner moved on half a second
later, before you had read the title. Any manual change now restarts the
countdown from that moment. (UR-034 → DR-038)
## v0.9.0
An audit release. One new setting you asked for, two naming bugs that only ever
+1
View File
@@ -36,6 +36,7 @@ went unexercised until a later feature leaned on them.
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
### Why they took so long to surface
+1
View File
@@ -698,6 +698,7 @@ Internal architecture, components, and application logic.
| UT-205 | Queued download paths cannot escape the download root — traversal, absolute and `..` forms are refused — while the four real path shapes the app builds, including the absolute one `download_series` produces, come back unchanged; and a completed download cannot register a file outside the root | DR-211 | Done |
| UT-206 | The offline item-type filter is bound rather than interpolated (a value containing a quote and `OR 1=1` matches nothing instead of disabling the `WHERE`), `build_get_items_endpoint` percent-encodes its values while preserving the commas Jellyfin splits on, and volume normalisation clamps out-of-range input and maps NaN to a finite value | DR-212 | Done |
| UT-200 | The stream a player could only restart is refused its retry: the handoff transcode answers yes to `player_retry_restarts_stream` while music, video and a downloaded episode answer no, and the Kotlin decision starts permissive, flips on a non-resumable load, and is restored by the next ordinary one | DR-203 | Done |
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
### Integration Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.9.0",
"version": "0.9.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+45 -2
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.0.18
pkgver=0.9.1
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
@@ -29,7 +29,45 @@ build() {
bun run build
# Only the raw binary is needed; packaging is done in package() below so we
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
(cd src-tauri && cargo build --release --locked)
#
# 🔴 `tauri/custom-protocol` is not optional. `tauri build` passes it for you;
# a bare `cargo build` does not, and without it Tauri loads the frontend from
# `devUrl` rather than the assets embedded from `frontendDist`. The result
# builds and installs cleanly and then cannot load its own UI. check() guards
# this.
(cd src-tauri && cargo build --release --locked --features tauri/custom-protocol)
}
check() {
cd "$_srcdir"
# A Tauri binary built without `custom-protocol` does not embed the frontend;
# it serves it from `devUrl` (http://localhost:1420) instead. It compiles,
# links and installs perfectly, then launches into "Could not connect to
# localhost: Connection refused" — which is what this package did for its
# entire existence, because `tauri build` adds that feature for you and a bare
# `cargo build` does not.
#
# Test for the *assets*, not for the dev URL: `devUrl` is part of the config
# blob that generate_context!() embeds either way, so its presence proves
# nothing. A content-hashed filename from the vite build can only be in the
# binary if the bundle was embedded — the with-feature binary is ~400 KB
# larger for exactly this reason.
local _binary="src-tauri/target/release/jellytau"
local _asset
_asset="$(basename "$(ls -1 build/_app/immutable/entry/*.js | head -n1)")"
if [ -z "$_asset" ]; then
echo "==> ERROR: no frontend build found — 'bun run build' did not produce build/_app." >&2
return 1
fi
if ! grep -qa "$_asset" "$_binary"; then
echo "==> ERROR: the frontend bundle is not embedded in the binary." >&2
echo " Build with --features tauri/custom-protocol, or the packaged app" >&2
echo " will start up unable to load its own UI." >&2
return 1
fi
}
package() {
@@ -42,6 +80,11 @@ package() {
install -Dm644 "packaging/arch/jellytau.desktop" \
"$pkgdir/usr/share/applications/jellytau.desktop"
# MIT is not in /usr/share/licenses/common, so Arch packaging requires the
# licence text to ship with the package.
install -Dm644 "LICENSE" \
"$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# Icons (hicolor)
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
+30 -8
View File
@@ -48,6 +48,9 @@ cd "$(dirname "$0")/.."
# Generated, vendored or build-output trees. Their markdown is not authored here
# and their link targets are not ours to fix.
#
# Only consulted when this is NOT a git checkout — inside one, the tracked-file
# list does this job and does not need maintaining. Kept for the tarball case.
EXCLUDES=(
"./node_modules/*"
"./.svelte-kit/*"
@@ -81,14 +84,33 @@ is_generated() {
echo "🔎 Checking relative markdown links resolve to files on disk…"
# Build the find(1) prune expression from EXCLUDES.
find_args=(. )
for pattern in "${EXCLUDES[@]}"; do
find_args+=(-path "$pattern" -prune -o)
done
find_args+=(-name "*.md" -type f -print)
mapfile -t md_files < <(find "${find_args[@]}" | sort)
# Ask git which markdown files are ours, rather than walking the filesystem.
#
# This started as a find(1) with a hand-maintained prune list, and that list was
# wrong three times in a row: it walked the scratch worktrees under .claude/,
# then makepkg's vendored cargo registry under packaging/arch/src/ — each time
# reporting a dependency's broken README as if it were ours. Every one of those
# directories is already git-ignored, so the tracked-file list is the exclusion
# rule, and it cannot drift out of date the way EXCLUDES did. It also matches
# what this script always claimed to do.
#
# Untracked-but-not-ignored files are deliberately included: a new doc added in
# a working tree should be checked before it is committed, not after.
if git rev-parse --git-dir >/dev/null 2>&1; then
mapfile -t md_files < <(
{ git ls-files -z --cached --others --exclude-standard -- '*.md' | tr '\0' '\n'; } \
| sed 's|^|./|' | sort -u
)
else
# Not a git checkout (an exported tarball, say): fall back to walking, with
# the prune list below as the only defence.
find_args=(. )
for pattern in "${EXCLUDES[@]}"; do
find_args+=(-path "$pattern" -prune -o)
done
find_args+=(-name "*.md" -type f -print)
mapfile -t md_files < <(find "${find_args[@]}" | sort)
fi
echo " ${#md_files[@]} markdown files"
+15
View File
@@ -72,6 +72,21 @@ if [ -f src-tauri/Cargo.lock ]; then
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
fi
# PKGBUILD — the Arch package version. Easy to miss because Arch packaging is a
# separate path from the tauri bundler, and missing it is exactly the failure
# this script exists to prevent: pkgver sat at 0.0.18 while the rest of the tree
# had moved on, so `makepkg` produced a package whose version bore no relation
# to the source it was built from. `pkgrel` resets to 1 because a new upstream
# version starts its packaging revisions over.
if [ -f packaging/arch/PKGBUILD ]; then
# Arch pkgver may not contain a hyphen (it separates pkgver from pkgrel), so a
# dev version like 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234, per the VCS
# package guidelines.
ARCH_VERSION="$(echo "$VERSION" | sed 's/-\([0-9]*\)-g/.r\1.g/; s/-/_/g')"
perl -0pi -e 's/^pkgver=.*$/pkgver='"$ARCH_VERSION"'/m' packaging/arch/PKGBUILD
perl -0pi -e 's/^pkgrel=.*$/pkgrel=1/m' packaging/arch/PKGBUILD
fi
# --- Android versionCode ----------------------------------------------------
# Only when the generated Android project exists (i.e. after `tauri android
# init`); on Linux/Windows jobs there is nothing to stamp.
+30
View File
@@ -54,6 +54,11 @@ function seed(dir: string) {
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
);
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
fs.writeFileSync(
path.join(dir, "packaging", "arch", "PKGBUILD"),
['pkgname=jellytau', 'pkgver=0.0.1', 'pkgrel=3', 'pkgdesc="x"', ''].join("\n")
);
}
function run(version: string, dir = tmp) {
@@ -178,4 +183,29 @@ describe("set-version.sh", () => {
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
});
});
// The Arch package is built by makepkg, not the tauri bundler, so its version
// lives in a file the rest of the release path never touches. It sat at
// 0.0.18 while the tree was on 0.8.x — makepkg happily produced a package
// whose version bore no relation to the source it was built from, which is
// the exact failure this script was written to prevent.
describe("PKGBUILD", () => {
it("stamps pkgver and resets pkgrel", () => {
run("0.9.0");
const pkgbuild = read("packaging/arch/PKGBUILD");
expect(pkgbuild).toMatch(/^pkgver=0\.9\.0$/m);
// A new upstream version starts its packaging revisions over.
expect(pkgbuild).toMatch(/^pkgrel=1$/m);
});
it("converts a dev version into a pkgver Arch accepts", () => {
// pkgver may not contain a hyphen — it is the pkgver/pkgrel separator.
run("0.9.0-3-gabc1234");
const pkgbuild = read("packaging/arch/PKGBUILD");
const match = pkgbuild.match(/^pkgver=(.*)$/m);
expect(match).not.toBeNull();
expect(match![1]).not.toContain("-");
expect(match![1]).toBe("0.9.0.r3.gabc1234");
});
});
});
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.9.0"
version = "0.9.1"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.9.0"
version = "0.9.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.9.0",
"version": "0.9.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+25 -7
View File
@@ -3,6 +3,7 @@
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { createRotationTimer, type RotationTimer } from "./heroRotation";
interface Props {
items: MediaItem[];
@@ -13,7 +14,7 @@
let { items, autoRotate = true, interval = 6000 }: Props = $props();
let currentIndex = $state(0);
let intervalId: number | null = null;
let rotation: RotationTimer | null = null;
// Touch/swipe state
let touchStartX = $state(0);
@@ -70,8 +71,22 @@
currentIndex = (currentIndex - 1 + items.length) % items.length;
}
// Manual navigation (swipe, arrows, dots) restarts the countdown, so the
// banner always waits a full interval after the last change instead of
// firing whatever was left of the previous one.
function showNext() {
next();
rotation?.restart();
}
function showPrev() {
prev();
rotation?.restart();
}
function goToIndex(idx: number) {
currentIndex = idx;
rotation?.restart();
}
// Touch/swipe handlers
@@ -96,10 +111,10 @@
if (Math.abs(diff) > swipeThreshold) {
if (diff > 0) {
// Swiped left - go to next
next();
showNext();
} else {
// Swiped right - go to previous
prev();
showPrev();
}
}
@@ -110,9 +125,12 @@
// Auto-rotate logic
$effect(() => {
if (autoRotate && items.length > 1) {
intervalId = window.setInterval(next, interval);
const timer = createRotationTimer(interval, next);
rotation = timer;
timer.restart();
return () => {
if (intervalId) clearInterval(intervalId);
timer.stop();
rotation = null;
};
}
});
@@ -219,7 +237,7 @@
<!-- Swipe Indicators (Desktop hover) -->
<button
onclick={prev}
onclick={showPrev}
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
aria-label="Previous item"
>
@@ -229,7 +247,7 @@
</button>
<button
onclick={next}
onclick={showNext}
class="absolute right-4 top-1/2 transform -translate-y-1/2 w-12 h-12 bg-black/40 backdrop-blur-sm rounded-full items-center justify-center transition-opacity opacity-0 group-hover:opacity-100 hover:bg-black/60 hidden md:flex"
aria-label="Next item"
>
@@ -0,0 +1,82 @@
// TRACES: UR-034 | DR-038 | UT-207
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRotationTimer } from "./heroRotation";
describe("hero banner rotation timer", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("advances once per interval while running", () => {
const onElapse = vi.fn();
const timer = createRotationTimer(6000, onElapse);
timer.restart();
vi.advanceTimersByTime(6000);
expect(onElapse).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(6000);
expect(onElapse).toHaveBeenCalledTimes(2);
timer.stop();
});
it("does nothing until started", () => {
const onElapse = vi.fn();
createRotationTimer(6000, onElapse);
vi.advanceTimersByTime(60_000);
expect(onElapse).not.toHaveBeenCalled();
});
// The bug: a manual swipe/click left the interval running, so the banner
// rotated again almost immediately instead of waiting a full interval.
it("restarts the countdown from now, not from the last auto-advance", () => {
const onElapse = vi.fn();
const timer = createRotationTimer(6000, onElapse);
timer.restart();
// 5.5s in the user swipes — the timer must restart from that moment.
vi.advanceTimersByTime(5500);
timer.restart();
// The remaining 500ms of the old countdown must NOT fire.
vi.advanceTimersByTime(500);
expect(onElapse).not.toHaveBeenCalled();
// A full interval after the swipe, it advances.
vi.advanceTimersByTime(5500);
expect(onElapse).toHaveBeenCalledTimes(1);
timer.stop();
});
it("does not stack timers when restarted repeatedly", () => {
const onElapse = vi.fn();
const timer = createRotationTimer(1000, onElapse);
timer.restart();
timer.restart();
timer.restart();
vi.advanceTimersByTime(1000);
expect(onElapse).toHaveBeenCalledTimes(1);
timer.stop();
});
it("stops firing after stop()", () => {
const onElapse = vi.fn();
const timer = createRotationTimer(1000, onElapse);
timer.restart();
timer.stop();
vi.advanceTimersByTime(10_000);
expect(onElapse).not.toHaveBeenCalled();
});
it("reports whether it is running", () => {
const timer = createRotationTimer(1000, () => {});
expect(timer.isRunning()).toBe(false);
timer.restart();
expect(timer.isRunning()).toBe(true);
timer.stop();
expect(timer.isRunning()).toBe(false);
});
});
+40
View File
@@ -0,0 +1,40 @@
// Auto-rotation timer for the home-screen hero banner.
//
// Extracted from HeroBanner.svelte so it can be unit-tested: a manual swipe or
// dot/arrow click must restart the countdown from that moment. The old code
// installed one bare setInterval and left it running, so swiping late in an
// interval made the banner jump to the next item almost immediately.
//
// TRACES: UR-034 | DR-038 | UT-207
export interface RotationTimer {
/** (Re)start the countdown from now, replacing any pending tick. */
restart(): void;
/** Cancel the countdown. */
stop(): void;
isRunning(): boolean;
}
/**
* Create a repeating timer that calls `onElapse` every `interval` ms once
* started. Restarting is idempotent there is never more than one live timer.
*/
export function createRotationTimer(interval: number, onElapse: () => void): RotationTimer {
let handle: ReturnType<typeof setInterval> | null = null;
function stop() {
if (handle !== null) {
clearInterval(handle);
handle = null;
}
}
return {
restart() {
stop();
handle = setInterval(onElapse, interval);
},
stop,
isRunning: () => handle !== null,
};
}