v0.11.6
237
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4bce81a800 |
chore(release): v0.11.6
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 4m4s
📱 Test APK / Build test APK (push) Successful in 48m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 9m17s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 22m27s
Build & Release / Build Linux (push) Successful in 31m18s
Build & Release / Build Windows (push) Successful in 30m1s
Build & Release / Build Android (push) Successful in 45m53s
Build & Release / Create Release (push) Successful in 1m15s
Seven fixes from an audit of the stack's most fragile seams, each with a test that fails without it. Two could take the app out entirely: an interrupted database migration left it unable to launch at all, and an unguarded panic at the Android JNI boundary aborted the process outright. Frontend gates (bun run test/check/lint/format:check) were not run for this release — node on the release machine is missing libada.so.3 and exits 127. All changes are under src-tauri/; CI runs those gates. |
||
|
|
65d3d912f7 |
fix(player): declare and enforce a lock hierarchy for PlayerController
The controller carries seventeen mutexes, reached from the MPV event loop,
JNI callbacks, sleep and autoplay timers, the session poller and every IPC
command. Nothing prevented two threads taking the same pair in opposite
orders, which deadlocks playback outright — and this subsystem has already
produced one deadlock.
No inversion exists today: the acquisitions really are scoped, and
`previous()` explicitly drops the backend guard before touching the queue.
That is the point. It holds by convention, convention is not checked, and
the failure it guards against is a frozen app with no error anywhere.
`LOCK_ORDER` writes the convention down, following the nesting the code
already relies on — `backend` before `queue` ("what is playing" before
"what is next"), `event_emitter` last because notifying the frontend must
never reach back for player state.
The tripwire only reports acquisitions that actually *overlap*, since two
locks taken one after another, each released before the next, cannot
deadlock. Verified by injecting a real inversion into `seek()`, which the
test located by line and rank.
|
||
|
|
192a8b3c67 |
fix(credentials): persist the fallback key instead of deriving an unstable one
The encrypted-file fallback derived its AES key from the hostname, a hardcoded salt and `$USER`. Two problems, and the second is the one users actually hit. It was never secret. Every input is readable by anyone who can read the ciphertext beside it, so the derivation bought nothing against the threat its name implies. Calling the result "AES-256-GCM encrypted" oversold it. And it was unstable. Renaming the machine, or launching from a context where `$USER` is unset — a systemd user service, some desktop launchers — changed the key and made every stored token undecryptable. `load_credentials_file` reports a failed decrypt as "no stored credentials", so this surfaced as being silently signed out with nothing to explain it. The key is now 32 random bytes persisted beside the credentials file, mode 0600, generated on first use. That is strictly better on both counts: higher entropy, and it does not move when the machine does. It is still obfuscation at rest rather than a secret — the key sits next to what it opens — and the module docs now say so plainly instead of implying otherwise. The keyring remains the only place a token is really protected. The old derivation is kept solely to read a file written by an earlier build; anything it opens is immediately rewritten under the persisted key, so no one is signed out by the upgrade. Verified against aarch64-linux-android as well as the host. |
||
|
|
747ec0161c |
fix(download): stop an empty download completing and then hanging the player
Two halves of one failure, either of which is enough to produce an
offline item that never starts.
The worker marked a transfer `completed` without checking it produced
any bytes, so a server that answered 200 with no body — an error page, a
transcode that yielded nothing — renamed a zero-byte `.part` into place
and published it as available offline. That is worse than failing: the
retry budget never applies and the UI shows the item as ready.
The media server then answered a request for that file with a span of
`{ start: 0, end: 0 }`. `end` is inclusive, so `Span::len()` reported
**one** byte: the response declared `Content-Length: 1` and streamed
nothing, which Chromium's media loader waits on forever. The user sees a
downloaded item that just never plays, with nothing explaining why.
A zero-length file has no satisfiable range, so `span_for` now returns
`None` and the server answers 416. An empty transfer is rejected as a
network error, which keeps the `.part` for a resume and lets the existing
retry budget do its job.
|
||
|
|
bc92eb4dea |
fix(repository): stop a slow cache read surfacing as a network error offline
The cache leg of a cache-first query had a hard 100 ms deadline that cancelled the read and reported it as a miss. That conflates "the cache has nothing" with "the cache was slow", and the two want opposite answers: offline the server leg fails too, so browsing surfaced a network error over cached content that was sitting on disk. It is not a rare race. The database is a single SQLite connection behind a single mutex, so a concurrent write — a sync drain, a bulk save_to_cache, a thumbnail write — blocks every read for its duration, and 100 ms is easily exceeded on phone storage. It also compounded: `spawn_blocking` work is not cancellable, so an abandoned query still ran and still held the mutex, making the next one slower. The deadline now bounds only the *fast path*. A cache query that misses it keeps running on its own task, and when the server leg fails the race waits that query out instead of discarding it. A cache that answers in time still short-circuits the server exactly as before, and when both sides genuinely fail the server's error is still what the caller sees. `parallel_race`/`race_with_refresh` no longer need `&self`, so they are associated functions and directly testable without constructing a repository. Not addressed here: one connection behind one mutex makes `PRAGMA journal_mode = WAL` inert, since reads and writes fully serialise regardless. A read pool is an architecture change and wants a spec. |
||
|
|
c72ca86865 |
fix(storage): stop a panic poisoning the connection mutex for the whole session
`RusqliteService` is the path every async database operation in the app takes, and all seven of its lock sites used a raw `.lock()`. A single panic while that guard is held poisons the mutex, after which every database call for the rest of the process returns "poisoned lock" — for a database-backed app, the entire UI stops working until restart. `utils::lock` exists to stop exactly this cascade, and `storage::Database` already used `lock_safe()`. The busiest lock in the app was the one that did not. The test poisons the connection the way a panicking row mapper would and asserts queries still serve. The same raw-lock pattern remains at ~121 command-layer sites on the `DatabaseWrapper`/`CredentialsWrapper` mutexes. Those degrade to a failed command rather than a panic, and converting them is a mechanical sweep better reviewed on its own. |
||
|
|
f9e1a8e69a |
fix(android): contain panics at the JNI boundary instead of aborting the process
Ten `extern "system"` callbacks are entered by the JVM on arbitrary threads. A panic unwinding out of one crosses the FFI boundary, which Rust answers by aborting: the app vanishes with no Java exception, no attributable stack trace, and no crash report the user can send. For callbacks that fire four times a second during playback that is the worst available failure mode. It was reachable. `nativeOnPositionUpdate` built a fallback Tokio runtime with `Runtime::new().unwrap()` on threads that have none, and `Runtime::new()` fails under exactly the fd exhaustion and thread-spawn refusal Android subjects a media app to. That now logs and drops the report — losing one progress report is recoverable, losing the app is not. Every callback body is wrapped in `jni_guard`, which catches the unwind and logs it. It is a backstop, not a licence to panic: a contained panic still leaves whatever it interrupted half-done. The guard lives in `player::jni_guard` rather than `player::android` because that module is `cfg(target_os = "android")` and so never compiles on the host — which is why its 1575 lines had no tests at all. A tripwire test asserts every entry point wraps its body, so an eleventh callback cannot reintroduce the defect; it reads the source, since exercising the real boundary needs a JVM. Verified with `cargo check --target aarch64-linux-android`. |
||
|
|
d4f80a4afa |
fix(storage): make each migration atomic so a partial failure can't brick the app
Migrations ran as bare `execute_batch` calls with the `_migrations` row written afterwards. SQLite autocommits every statement, so a migration that died partway — low disk, an OOM kill, the process dying mid-boot — left its earlier statements applied and recorded nothing. That is unrecoverable rather than merely untidy. `execute_batch` aborts on the first error, so the retry on the next launch failed at statement 1 with "duplicate column name" and kept failing forever, and `Database::open` turns a migration error into a `panic!` — the app never started again and the only fix was clearing app data, losing downloads and logins. Several migrations have exactly the shape that triggers it: 006 is three `ADD COLUMN`s, 003/005/024 are full table rebuilds. Each migration now runs in one transaction with its `_migrations` row committed inside it, so a migration is all-or-nothing and a retry is always safe. Every migration is pure DDL/DML, which SQLite runs transactionally; a `PRAGMA` or `VACUUM` added to one would not roll back. `migrate()` delegates to a new `migrate_with()` so a test can inject a deliberately-failing migration. |
||
|
|
7b738002a0 |
fix(ci): move MIGRATION_025 above the test module
clippy's items_after_test_module fired on schema.rs: the new migration
const was appended to the end of the file, which is after the
#[cfg(test)] block added alongside migration 024.
error: items after a test module
--> src/storage/schema.rs:901:1
Only visible under --all-targets, which compiles the test target; the
--lib run I checked locally cannot see it. CI runs --all-targets, so it
failed there and nowhere else. Verified this time with the exact CI
invocation rather than a narrower one.
|
||
|
|
c41b8ec896 |
fix(library): record which library a cached item came from
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 13m40s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 43s
📱 Test APK / Build test APK (push) Successful in 49m21s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 8m39s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
"TV" and "Shows" showed identical contents, and so would any two libraries of the same type. save_to_cache bound library_id NULL on every row it wrote, so nothing in the cache knew where an item came from. The only association available was the collection_type/item_type taxonomy, and that is unable in principle to tell two libraries of one type apart -- both are 'tvshows', so every Series on the server satisfies either. DR-277 narrowed the library clause, which stopped Books and Photos serving the whole server, but no clause over that taxonomy could have fixed this. The write path is the single choke point every cached row passes through and it already knows the parent being browsed, so it now resolves the owning library once per call: the parent itself when it is a library, otherwise the library its parent item was filed under, which carries the association down a hierarchy as it is browsed. Synthetic parents like "favorites" match neither and stay NULL -- they are not a library and span several. This is what makes the taxonomy stop being load-bearing. Library types nobody enumerated -- Books, Photos, Collections, mixed libraries with no collection type at all -- are now scoped by the same link as everything else rather than by whether someone remembered to add an arm for them. Existing rows cannot be repaired locally, because the association was never stored: migration 025 clears synced_at to force a re-fetch, the same move MIGRATION_018 made for is_folder. Nothing is deleted -- downloads, favourites and playback positions live in other tables, and a cleared synced_at only means "ask the server again". The new tests seed through save_to_cache rather than inserting rows directly, so they exercise the path that was actually broken. |
||
|
|
dea78b89b9 |
test(library): pin collections, both as a library and as an item
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m24s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m3s
📱 Test APK / Build test APK (push) Successful in 48m0s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 7m55s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 6m26s
"Is Collections broken too?" deserved an answer from the suite rather than from reading the query. Both, and they behave differently. A Collections *library* was hit by the same defect as Books and Photos -- unmapped collection_type, no include_item_types, so the library clause matched every cached row -- and is fixed by the same change. The unknown-type test now covers boxsets, photos, homevideos and the empty collection_type Jellyfin sends for a mixed library, instead of standing on books alone. An individual collection is a different path and keeps working: a BoxSet's members carry parent_id, which the cache does store, so they match the ordinary parent link rather than the library clause. That is worth its own test because narrowing the clause could plausibly have taken collections with it, and "Collections is empty" would look identical to the bug being fixed. |
||
|
|
368935e6f4 |
fix(library): scope a library listing to that library
Opening a library that is not Music, Movies or TV served whatever
happened to be cached — films under Books, albums under Photos — rather
than the library's own contents.
The cached-browse query matched a library parent with an EXISTS that
never referenced the item:
OR EXISTS (SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id)
It asks only whether a library with the requested id exists, so it is
true for every cached row the moment the parent is any library. The three
typed libraries concealed it because their landing pages pass
include_item_types, which narrowed the result to albums or films or
series; the generic library page passes none, so nothing narrowed it at
all.
`library_id` now decides wherever the cache kept one. That is the
server's own answer, and the only thing that can scope a library whose
type has no mapping (Books, Photos, Collections) or none at all — a
mixed library, where Jellyfin sends CollectionType null. The
collection_type/item_type taxonomy stays as the fallback for rows written
before the link was stored, and a library with neither matches nothing
and falls through to the server, which does know what is in it.
The taxonomy is now one macro shared with the downloaded listing. That
listing had the identical defect and it was fixed there alone (DR-167) —
the comment there even says the mapping "is needed in two places that
must agree", which was true of a third place nobody looked at.
One existing assertion changed rather than being worked around:
UT-206 expected a lib-2 album back from a lib-1 listing, which only held
because of this bug. It is about parameter binding order, so it keeps
testing exactly that, now with an album that is really in lib-1.
|
||
|
|
5fb9c1ff3b |
ci: publish a rolling latest APK on every push to master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m52s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 46s
📱 Test APK / Build test APK (push) Failing after 1m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 7m54s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 6m26s
The test-APK workflow was dispatch-only, so merging to master produced no APK at all -- there was nothing to hand a tester without pressing a button first, which is not what a "latest build" means. Pushes to master now refresh a `latest` pre-release in place. Both the tag and the asset name are stable, so the download URL never changes and a link given to a tester once keeps serving the current build. Release assets are public; Actions artifacts need an account, which is what made them useless for this. It stays the side-by-side variant: R8-minified like a real release, so it still exercises the minification that has broken Android builds here before, but signed with the debug keystore under the `.debug` applicationId. A bad master commit therefore cannot replace anyone's working install, and the production signing key stays in the tag-driven release workflow. Event handling is resolved in one step rather than read raw at each use. A push carries no dispatch inputs -- every `github.event.inputs.*` is empty on that event -- so the variant and ABI need real defaults, and the publish decision differs by event. Doing it once means the build, collect and publish steps cannot disagree about what the run is. Known gap, documented rather than hidden: this builds in parallel with build-and-test.yml, so `latest` can carry a commit whose tests later fail. Cross-workflow dependencies are not reliably available here and duplicating the test job would double an already hour-long queue on a single-slot runner. |
||
|
|
10d77f1380 |
ci: publish a test APK as a pre-release for outside testers
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 29m49s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 9m25s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
🏗️ Build and Test JellyTau / Android Compile Check (push) Failing after 51s
Gitea artifacts need an account with read access to download, which makes them useless for handing a build to someone outside the project -- the actual reason a test APK gets built in the first place. An optional publish input attaches the APK to a pre-release instead, whose assets are a plain public URL on a public repo. No merge to master, no MR, no version tag, and the tester needs no account. Safe from a feature branch on two counts. The tag is test-<branch> rather than v*, and only v* triggers build-release.yml, so nothing else reacts to it. And it cannot reach existing users: the desktop updater reads a static latest.json from the updater branch, not the release list. Re-dispatching the same branch replaces the APK on the existing pre-release rather than accumulating one release per attempt. |
||
|
|
03c0b5cd17 |
ci: build a test APK from any branch on demand
build-release.yml is tag-driven, builds three platforms and then creates a release -- none of which is what you want from a feature branch, and there was otherwise no way to get an installable build out of CI without cutting one. workflow_dispatch only, deliberately. The runner has a single slot shared with two other projects, so an APK on every feature-branch commit would starve them; dispatch it when you actually want to install something. Defaults to the R8-minified release build in the debug applicationId slot rather than a plain debug APK. Minification is where Android releases have actually broken here (R8 stripping JNI-loaded player and security classes), and a debug build cannot catch it. Neither variant needs the real signing key, and both install side by side with a real install. Builds through scripts/build-android.sh rather than a hand-rolled tauri invocation, so CI and a developer's machine produce the same thing and the script's applicationId assertion still runs. Shares the existing cargo registry cache key -- no fourth copy of the registry on a disk that has filled before. |
||
|
|
da762da55d |
feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md). |
||
|
|
8a04a6fad0 |
chore(release): v0.11.5
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m44s
Build & Release / Build Linux (push) Successful in 20m30s
Build & Release / Build Windows (push) Successful in 15m38s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 37s
One fix since v0.11.4: a video in a picture-in-picture window no longer drops to audio-only partway through, replaying from wherever the picture had been when the window opened. Two independent faults had to line up for it. The player's position variable is written only by a requestAnimationFrame loop while playing, and RAF stops for a document that is not being rendered -- which an Android activity behind a PiP window is not; the `timeupdate` handler that would have covered the gap had gated itself on `!isPlaying` since the first commit. And PiP and the background-audio handoff, nominally alternatives, could both be armed at once, with a single `isInPictureInPictureMode` sample taken inside onStop() standing between them. The frozen position was not confined to the handoff: the seek bar, resume points and the progress reported to Jellyfin all read the same variable, so all three stood still for as long as a PiP window was open. Version stamped with scripts/set-version.sh. defect-windows.md records DR-265 under the present-since/reachable-since gap: defective since v0.0.1, but only hittable once PiP started working on the HTML5 path in v0.5.3. |
||
|
|
66e7889030 |
chore(release): v0.11.4
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m51s
Build & Release / Build Linux (push) Successful in 20m25s
Build & Release / Build Windows (push) Successful in 15m29s
Build & Release / Build Android (push) Successful in 30m41s
Build & Release / Create Release (push) Successful in 45s
Three fixes since v0.11.3: Recently Added groups a new album's tracks into one album card in the client rather than trusting the server's GroupItems; autoplay crosses a season boundary instead of stopping dead at the last episode; and a finished episode is no longer offered as its own "up next". Plus the Android debug applicationId suffix, which the Tauri CLI's Gradle rewrite had been dropping, so a debug build again installs beside a release one. Version stamped with scripts/set-version.sh. defect-windows.md records the Recently Added grouping as the second fix for the same symptom: v0.5.1 sent GroupItems=true and the server does not always honour it. |
||
|
|
d25f6be697 |
fix(catalog): group Recently Added tracks into albums in the client
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m37s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Recently Added still listed a newly-imported album one song at a time. GroupItems=true asks Jellyfin to collapse leaves into their container, but the server only groups a track whose parent chain actually resolves a MusicAlbum, and older servers ignore the parameter outright — so the raw leaves kept arriving. The home row passes no library, so the offline branch (which collapses in SQL) contributes nothing there and the server's answer *is* the row. Group again in the online repository, so the shape of the row is a property of this app rather than of the server it happens to be talking to: - A track naming an album_id collapses into one MusicAlbum card, placed where the first of its tracks stood so recency order survives. The card keeps the artwork, album name and artists; track number, duration, album link, streams and per-track user data stay with the leaf. - If the server did return the album row, that row wins and its tracks are dropped — it carries detail a track-built stand-in cannot. - Tracks with no album, movies, episodes and folders pass through untouched. - Collapsing only shrinks a listing, so the request over-fetches 3x and truncates afterwards; otherwise one 14-track import left the row nearly empty. Episodes are still grouped only by the server, so a freshly added season can flood the row the same way — same fix applies if it shows up. |
||
|
|
079153d9d5 |
fix(library): stop offering the episode you just finished as "up next"
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m0s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 35s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m27s
Finish an episode, leave the player with Back, and the season view still put the yellow ring and the "Up next" badge on the episode that had just ended -- and scrolled to it. Nothing records completion locally. storage_update_playback_progress writes a position and never touches is_played, and mirror_user_data carried the server's favourite flag and position but not its played flag, so that column was written by nothing except an explicit local toggle. By the second visit to a series page get_items is a cache hit, so every episode reads back unwatched; meanwhile Jellyfin's Next Up is still one stop-report behind and names the episode that just ended. pick_current_episode had no reason to disagree with either of them. is_finished -- the played flag, or a position at or past MAX_PROGRESS_FRACTION of the runtime, the same 95% threshold that already disqualifies an episode from counting as in-progress -- replaces the bare is_played in the furthest-watched scan and the first-unwatched fallback, and screens the Next Up candidate before it is accepted: the server is briefly behind, the local position is not. The current episode becomes the next one, and the highlight, the badge, the auto-scroll and which season starts expanded all follow it. mirror_user_data now carries is_played alongside the rest, under the same pending_sync = 0 conflict rule, so watched state survives a cache write instead of being dropped -- which is also what puts the checkmarks back in the season list. TRACES: UR-025, UR-062 | DR-264 | UT-239, UT-240 |
||
|
|
1ba836928f |
fix(android): keep the debug applicationId out of Tauri's reach
`bun run android:dev` produced an APK whose applicationId was plain
com.dtourolle.jellytau, so installing it over a real release build failed
with INSTALL_FAILED_UPDATE_INCOMPATIBLE -- the only obvious way out being to
uninstall the release app and lose its data.
`tauri android build` rewrites the getByName("debug") block in the generated
copy of build.gradle.kts to inject its jniLibs.keepDebugSymbols entries. The
damage is visible in the generated file, where `packaging {` ends up with the
first injected line welded onto it. That rewrite drops applicationIdSuffix
and nothing else -- versionNameSuffix and the manifest placeholders beside it
survive -- and it happens after sync-android-sources.sh has copied the
canonical file into place and before Gradle configures, so no amount of
syncing beats it. The sideBySideRelease suffix in the release build type is
untouched by the same rewrite, which is why `build-and-deploy.sh release
--debug` kept working while the plain debug path did not.
The suffix moves to a top-level statement after the android {} block, which
is not inside what the rewriter looks for and survives. build-android.sh then
asserts the applicationId the APK actually carries, read from AGP's
output-metadata.json, so a future CLI that reaches further fails the build
instead of shipping a colliding APK.
Verified: a debug build now reports com.dtourolle.jellytau.debug, and the
statement is still there in gen/ after the CLI has run.
|
||
|
|
bbccc8567c |
fix(player): play on past the end of a season
Autoplay listed the episodes of the current season and stopped dead at the last one, so the end of a season returned AutoplayDecision::Stop. On the Android background-audio handoff that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it -- the device log reads "Current episode is the last in the season" and then "Decision: Stop playback", one episode after a mid-season boundary the backend advanced through by itself. The lookup now walks the series' seasons and takes the first episode of the next one that has any. Seasons are sorted client-side by index number because the offline repository ignores sort_by, empty seasons are skipped rather than read as the end of the series, and Specials are never rolled into: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would otherwise land. Every autoplay entry point shares this lookup, so foreground video and the Android native path cross the boundary too. It stays below the sleep-timer gate in on_playback_ended, so a timer set to end-of-episode or a remaining episode count still stops at the boundary instead of being carried past it. TRACES: UR-023, UR-040 | DR-263 | UT-238 |
||
|
|
ad05dcd484 |
chore(release): bump version to v0.11.3
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m42s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m24s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Build & Release / Run Tests (push) Successful in 15m5s
Build & Release / Build Linux (push) Successful in 27m43s
Build & Release / Build Windows (push) Successful in 25m31s
Build & Release / Build Android (push) Successful in 50m49s
Build & Release / Create Release (push) Successful in 4m2s
Stamped with scripts/set-version.sh, which also picks up packaging/arch/PKGBUILD — stale at 0.10.1, since the last two bumps edited the version files by hand. |
||
|
|
1d6487774c |
fix(android): subtitles on the picture, not on a black bar
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m35s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 40s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Every subtitle line arrived in an opaque black box wide enough to sit across the picture. That box is what Android hands back when the viewer has set no captioning preferences: SubtitleView.setUserDefaultStyle() reads the system style and falls back to media3's DEFAULT, which is white on opaque black. Dropping the box is not the same as replacing the style. Someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and overriding all of it to remove a background would answer a question they did not ask. Their style is kept and only the two colours that paint a box — background and window — are cleared. A style specifying no edge gets a black outline, since without a box the text must supply its own contrast or it is unreadable over a bright scene. One that already names an edge keeps it: that viewer has said how they want their captions separated from the picture. Compiles and packages, but NOT yet seen on a device — the tablet was disconnected before it could be deployed, so the requirement is recorded as "Done (pending device verification)" and this must not be tagged into a release until someone has looked at it. TRACES: UR-020 | DR-261 |
||
|
|
1aec38b760 |
chore(release): bump version to v0.11.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 15m6s
Build & Release / Build Linux (push) Successful in 21m8s
Build & Release / Build Windows (push) Successful in 15m55s
Build & Release / Build Android (push) Successful in 31m16s
Build & Release / Create Release (push) Successful in 39s
|
||
|
|
0187ee179e |
fix(android): draw the subtitles the player already decodes
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s
Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer decodes subtitles and delivers them to a listener; it draws none itself. A PlayerView would supply the view that does, but native video here is a bare TextureView the WebView composites over — so nothing held the cues and every one was decoded, delivered and dropped. There was no onCues, no TextOutput and no SubtitleView anywhere in the app, and media3-ui was not even a dependency. The gap was invisible for as long as every subtitle URL 404ed: with no text track to select there was never a cue to lose, so fixing the URL is what exposed it. media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of the content view — above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is removed by the same teardown that detaches the surface (the defect DR-184 exists to prevent). Verified on a device: track selected with no "Invalid subtitle track index", SubtitleView attached at the fitted rect per the live view hierarchy, and cues legible on screen during playback. TRACES: UR-020, UR-003 | DR-260 |
||
|
|
a94632461b |
chore(release): bump version to v0.11.1
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 15m4s
Build & Release / Build Linux (push) Successful in 20m48s
Build & Release / Build Windows (push) Successful in 16m14s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 33s
|
||
|
|
64de22bd51 |
fix(player): change the audio track, and load subtitles at all
Two faults, both present since v0.0.1, both found and confirmed on a device. Audio track (DR-258). Jellyfin builds a transcode around one AudioStreamIndex, so the alternate tracks are not in the stream that arrives — but the native path only ever called setAudioTrack(n), which indexes ExoPlayer's audio track *groups*. On Android that is the common case, since any source whose default audio codec the device cannot decode is transcoded: logcat showed ExoPlayer holding `Audio tracks: 1` while the menu listed every track in the file, so each selection warned `Invalid audio track index` and was dropped, leaving the default track playing with nothing in the UI saying so. determine_audio_track_switch_strategy now decides by whether the stream in front of the engine carries the track at all — a direct play still selects in place, a transcode is re-negotiated at the chosen index and resumed. Where it resumes is the player's answer rather than the UI's: the native path has no <video> element to read, so it sends no position, and defaulting that to zero re-opened the film at the beginning (caught on device before it shipped). Subtitles (DR-259). The URL was missing its `Stream.` route segment, so every sideloaded subtitle 404ed; since media3 1.5 a sideloaded text track only becomes a track group once its file is parsed, so 42 failed fetches left ExoPlayer with no text tracks and selection warned `available: 0`. Verified against a live server: the built URL answers 404, the corrected one 200. The tests that should have caught this asserted the shape of a mock helper that restated the format string instead of the URL the app requests — so the new test drives the repository itself, and failed red on the old URL. |
||
|
|
231ffae626 |
fix(library): podcasts list newest episode first
A Jellypod podcast listed its episodes alphabetically. The store pinned SortBy=SortName onto every drill-down, which overrode the order the channel plugin returns — and since Jellypod prefixes played episodes with "[Played]", the name sort also clumped every heard episode at the top. Which order a container's children take is domain knowledge, so it moves to Rust: the caller names the container (GetItemsOptions.parentKind) and default_listing_sort answers with the sort. A channel folder is PremiereDate descending, every other container keeps SortName ascending, and a caller naming no container still gets no SortBy, so the paths that rely on the server's own order keep it. An explicit sort always wins. ChannelFolderItem with is_folder now maps to MediaKind::ChannelFolder instead of collapsing into Folder — while both were Folder there was nothing to key the rule on. The offline leg of the cache/server race applies the same order, so the cached list no longer flashes in name order before the server's arrives. TRACES: UR-007 | DR-257 | UT-229, UT-230, UT-231 |
||
|
|
bb14c66e71 |
fix(player): three defects from review, and one duplicate removed
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m37s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 2m55s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 15m34s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m26s
Build & Release / Build Linux (push) Successful in 20m58s
Build & Release / Build Windows (push) Successful in 16m10s
Build & Release / Build Android (push) Successful in 31m25s
Build & Release / Create Release (push) Successful in 1m5s
Verified each against the code before acting; four of the five findings held, one did not. DR-253 — a deferred seek outlived its file. `seek` holds a position while MPV has nothing loaded and `FileLoaded` applies it (DR-241), but neither `load` nor `stop` discarded it. Scrub near the end of a transcoded item — which re-opens the stream — then skip to the next item before the reload completes, and the old position lands on the new item. It starts wherever the previous one was scrubbed to, silently. Both lifecycle points clear it now. DR-254 — a per-playback quality ceiling outlived its playback. The override is process-wide and describes one playback: dropping to 720p for a struggling episode says nothing about the next. Every advance the frontend drives clears it through player_play_item, but the background audio-only advance loads the next episode in Rust and skipped all three clearing sites — so every later episode stayed capped, with nothing in the UI explaining why. DR-255 — `playable_url` was a byte-identical copy of `playback_url`, added for the cross-platform open path. The original is `#[cfg(target_os = "android")]`, so it does not exist in a Linux build and nothing warned. Two matches over MediaSource meant a new variant could be handled in one and forgotten in the other. The gate is gone and the copy with it. The fifth finding — that the comment on `video_audio_codecs` describes a renderer switch the code no longer has — does not hold. `get_player_status` hard-codes Android to Native, but `experimentalNativeVideo` is still live in VideoPlayer.svelte as a suppressor that can force HTML5 even when Rust says native. The switch exists, so the narrow codec list is still doing its job. Both correctness fixes are red-then-green. The tests are wiring assertions in the style of UT-218: what matters is the call site, and reaching these at runtime needs a live MPV handle or a repository, a server and a player. That technique now appears three times and is worth watching — it pins call sites, not behaviour. The review's sharpest point is one it raised as redundancy: MpvPlayer already handles DR-253 correctly, resetting deferred state on every open, and the old path had to be patched separately. That is the drift two parallel engines produce, and the argument for finishing DR-248/249 rather than leaving LegacyPlayer in place indefinitely. 795 Rust tests, 1088 frontend, every CI check green locally. |
||
|
|
fd8273824a |
test(player): drive an engine that answers badly
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 42s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m46s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m53s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 15m15s
Build & Release / Build Linux (push) Successful in 21m14s
Build & Release / Build Windows (push) Successful in 15m53s
Build & Release / Build Android (push) Successful in 31m34s
Build & Release / Create Release (push) Successful in 53s
Fair criticism: hardware time went into writing a checklist describing what the
tablet found, when it should have gone into making the suites able to find it.
A checklist decays and depends on someone following it. A test does not.
The gap was specific. Every engine the conformance suite drives reports sane
numbers, so it stayed green while a real one took the backend down. The old
PlayerBackend contract is a plain f64 — it never promised finite, never
promised positive, and nothing enforced it.
UT-223 adds the engine that was missing: a HostileBackend answering with
C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero. Reading a
snapshot must yield no duration and a zero position rather than panicking.
Against the adapter as originally written it fails with
cannot convert float seconds to Duration: value is negative
which is the exact panic that produced a black screen on the tablet — now
reproduced in 0.00s on a laptop instead of by backgrounding an app.
UT-224 pins the other hardware-only finding: stopping clears an active
background-audio handoff, flag and base offset both. That was verified by
listening to a device, which is not a test.
Both were confirmed to fail against the pre-fix code before being kept.
The verification plan now says to prefer moving cases out of it and into tests,
and that what remains should be what genuinely needs eyes, ears or a display —
not what merely has not been automated yet.
793 Rust tests.
|
||
|
|
11d9d760d8 |
feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
|
||
|
|
5fede123e7 |
fix(deps): take the patched quick-xml via plist 1.10
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m18s
cargo-deny went red on master with two quick-xml DoS advisories (RUSTSEC-2026-0194, RUSTSEC-2026-0195). They were absent before, and correctly so: quick-xml reached the graph only through plist on Apple targets, and deny.toml scopes the graph to the targets this project actually ships. The Tauri 2.11 upgrade changed that. plist is now pulled in by tauri-utils, which is a build-dependency of tauri-build, so it compiles on every target including Linux and the advisory became genuinely in scope. That is the gate behaving as designed -- silent while the crate was unreachable, loud the moment a dependency upgrade brought it into a build we ship. Fixed rather than ignored. plist 1.10.0 requires quick-xml ^0.41.0, which carries both patches, and tauri-utils accepts plist ^1, so the upgrade is a lockfile change with nothing else moving: plist 1.8.0 -> 1.10.0 quick-xml 0.38.4 -> 0.41.0 An ignore entry would have been easy to justify here -- build-time only, parsing files we generate, absent from every shipped binary -- and that is exactly why it would have been wrong: the justification would have outlived the reason for it, and the entry would still be sitting in deny.toml long after the upgrade became available. No release. quick-xml is a build dependency, so it is not inside any v0.10.1 artifact; this only restores master to green. Verified: cargo deny (advisories, bans, licences, sources all ok), cargo check, 765 tests, cargo fmt --check, clippy -D warnings. |
||
|
|
edff6eedc9 |
fix(player): let the background-audio toggle govern backgrounding again
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
Locking the screen kept a video's audio playing whether or not the background-audio button was on. Reported as "audio only mode is always active even if not selected". The button (UR-040) was built for the WebView <video> path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a MediaSessionService -- a foreground media service whose entire purpose is to keep playing while the app is hidden. Nothing stopped it, and nothing in the codebase paused on background. So the button governed a handoff that no longer had a gap to bridge. There was no interruption to paper over, and a user who never touched it got background playback anyway. The gating made it self-concealing: MainActivity.onStop only dispatched 'jellytau-background' when backgroundAudioEnabled was already true. The one notification that the app had gone away was itself conditional on the setting, so with the button OFF nothing could react even in principle. onStop and onStart now fire unconditionally and carry the two facts only the activity knows -- whether the toggle is armed, and whether Android put the window into picture-in-picture. What to do about it is decided in Rust (player/background_policy.rs), because it depends on whether the item has a picture to lose: video + toggle off -> Pause video + toggle on -> HandOffToAudio music, either -> KeepPlaying (no picture to give up) picture-in-picture -> KeepPlaying (the window is still on screen) It takes no renderer parameter on purpose. Two renderers with two behaviours and one toggle reaching only one of them is what produced the defect; a rule that cannot see the renderer cannot reproduce it. Two failure modes are deliberate. A decision call that fails leaves playback alone rather than risking silence mid-listen. An event with no detail -- older Kotlin against newer JS -- reads as "armed, not PiP", degrading to the previous behaviour instead of pausing unexpectedly. Foregrounding resumes only what backgrounding paused: a video the user paused themselves before locking stays paused. Written test-first per CLAUDE.md. The stub encoded today's behaviour (nothing ever pauses) and failed exactly as reported -- `left: KeepPlaying, right: Pause` -- before the rule was implemented. Verified on a device, R8-minified, both directions: [player_background_action] video=true armed=false pip=false -> Pause [player_background_action] video=true armed=true pip=false -> HandOffToAudio UR-040 / DR-224 / UT-211. |
||
|
|
76a2d9609b |
fix(release): produce updater artifacts, and point the manifest at them
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m32s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 29s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m22s
Build & Release / Build Linux (push) Failing after 17m42s
Build & Release / Build Windows (push) Successful in 15m46s
Build & Release / Build Android (push) Successful in 30m54s
Build & Release / Create Release (push) Skipped
Two defects on the release path, both of which would have failed the v0.10.0 build after all three platforms had already compiled -- caught by running a real signed build locally instead of waiting for the tag. **createUpdaterArtifacts was never set.** Without it Tauri emits only the plain .AppImage and .exe: no signatures at all. The manifest step then finds none and aborts by design, so the release dies at Create Release having spent ~40 minutes building artifacts it cannot publish. **The manifest looked for the wrong filename.** Tauri v2 signs the .AppImage *itself* and writes <name>.AppImage.sig beside it. The .AppImage.tar.gz form this workflow globbed for only exists under createUpdaterArtifacts: "v1Compatible". A real signed build produced: 154M JellyTau_0.10.0_amd64.AppImage 420 JellyTau_0.10.0_amd64.AppImage.sig so the glob would have matched nothing and the step would have aborted for a second, entirely different reason. Both the artifact collection and the manifest now use the v2 names, and the AppImage and its .sig ship together -- a manifest referencing a signature that was never uploaded fails only on the user's machine. Verified before tagging rather than after: the manifest logic was run against the real artifacts (420-char minisign signature read correctly) and the resulting latest.json checked for validity and shape. The Windows side already used the correct pattern (<installer>.exe.sig), which is why only Linux needed the change. |
||
|
|
30a9cb32f5 |
chore(release): v0.10.0
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 27s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Build & Release / Run Tests (push) Successful in 18m0s
Build & Release / Build Linux (push) Failing after 17m37s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 31m6s
Build & Release / Create Release (push) Skipped
Two user-visible features -- the app can update itself, and it can hand you a redacted diagnostics bundle -- plus the supply-chain, release integrity and build work behind them. A minor bump rather than a patch, matching how v0.9.0 was cut off v0.8.2 for a single new user requirement. This one carries two (UR-077, UR-078), both with UI in Settings. The CHANGELOG entry is the release body now: build-release.yml publishes the `## v0.10.0` section and fails if it is missing, instead of the fixed block of install instructions that every release from v0.0.1 to v0.9.1 carried verbatim. |
||
|
|
214997144f |
feat(deps): upgrade Tauri to 2.11.5, and own the Android context it stopped setting
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m51s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Failing after 25s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m19s
The plugin versions could not be matched upward without this: both
tauri-plugin-log 2.9.0 and tauri-plugin-updater 2.10.1 require tauri
^2.10, and the tree was on 2.9.5. So the framework moves with them --
tauri 2.9.5 -> 2.11.5, tauri-build 2.5.3 -> 2.6.3, wry 0.53.5 -> 0.55.1
-- and every plugin's Rust crate and npm package is now pinned to the
same version on both sides.
That upgrade broke Android outright, and the breakage is the interesting
part.
Seven call sites in this crate reach JNI through
ndk_context::android_context(), which reads a process-global pair of
pointers. Nothing here ever set that global. `tao` did -- the windowing
layer under wry, three levels below anything this project names in
Cargo.toml. tao 0.34.5 called initialize_android_context() while starting
the activity and our code read what it left behind. tao 0.35.3 keeps the
same two pointers in a private struct and no longer publishes them.
The result, on every launch, was:
PANIC at ndk-context/src/lib.rs:72: android context was not initialized
8: ndk_context::android_context
9: jellytau_lib::run::{{closure}}
Not a crash in our code, and not a change to our code: an undocumented
side effect of a transitive dependency disappeared. Relying on someone
else to populate a global is a dependency that does not appear in
Cargo.toml and gives no warning when it goes.
src-tauri/src/android_context.rs now owns that invariant instead of
assuming it. JNI_OnLoad captures the JavaVM as the shared library loads
-- the earliest moment available, and nothing in tao, wry or tauri
defines one to collide with. The Context is resolved lazily via
ActivityThread.currentApplication() and pinned as a global reference for
the process lifetime, since ndk_context stores a bare pointer and does
not own it. It publishes the Application rather than the Activity:
SecureStorage.initialize() immediately reduces its argument to
applicationContext anyway, and an Application cannot outlive itself the
way a retained Activity would.
Restoring the global keeps all seven callers untouched. Threading a VM
and Context handle through five credential call sites would have been a
larger change with more risk, on the credential path.
Failure now degrades instead of aborting: it is logged and credentials
fall back to the encrypted-file path, which the app already supports.
Verified on a device, R8-minified, not merely compiled:
[INIT] Android JavaVM and Application published to ndk_context
Android SecureStorage initialized successfully
Android Keystore available via SecureStorage
[INIT] Using system keyring for credential storage
[CodecDetection] Detected 7 video codecs: av1,h263,h264,hevc,...
-- the real keystore path, not the fallback, and the app stays up. None
of this is reachable by CI: nothing there runs the app.
Also fixed here, both found the same way:
- `tauri android build --apk true` is now `--apk`. The CLI took a value
until 2.10; from 2.11 the stray `true` is a positional and the build
fails before starting. Three call sites in build-android.sh and one
in build-release.yml -- the latter builds the signed APK, by far the
most-downloaded artifact.
- scripts/build-android.sh ran `npm install` on its clean-build path in
a bun project, ignoring bun.lock and re-resolving the tree. That is
exactly how the plugin crate/package versions drift apart again.
scripts/check-tooling.sh now fails on any npm/yarn/pnpm invocation or
foreign lockfile, and runs in CI.
DR-222, DR-223.
|
||
|
|
f3fa45f742 |
feat(diagnostics): persistent redacted logging and an exportable bundle
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.
Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.
Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.
Two things the tests caught that review would not have:
- redact_headers recursed on its own output. The replacement keeps the
header NAME, so the next call matched the same header forever; the
test died with a stack overflow. It is a forward scan now.
- The frontend forwarder used `void plugin.error(...)`. `void` discards
a promise's value but not its rejection, so in any webview without
IPC -- a unit test, SSR, a browser preview -- every log line became an
unhandled rejection. 20 of them showed up the first time coverage
ran. Each call now attaches a catch.
Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.
Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.
The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.
Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.
Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
|
||
|
|
3211c96ecf |
feat(updater): in-app update on desktop, releases link on Android
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.
Desktop now checks a signed manifest, shows the version and its notes in
Settings, and installs and relaunches on request. The signature check is
the whole point: it is what stops a substituted download from being
installed by the app itself. Windows binaries stay unsigned for
SmartScreen purposes -- that is a code-signing certificate, a separate
problem -- but the update payload is verified against our own key.
Android is deliberately not wired to the updater. An app may not replace
its own APK; that is the package installer's job, and the plugin has no
Android implementation. It gets a link to the releases page instead of a
button that would throw.
The plugins are gated with a target-triple cfg rather than
cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)']
table, so cfg(desktop) matches nothing, silently drops the dependency,
and fails much later with "Permission updater:default not found" -- which
is exactly what the first attempt here did.
Where the manifest lives took some finding. This Gitea serves
/releases/download/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (verified against a real asset), so
there is no stable latest-release URL. The gitea-pages branch is
force-pushed wholesale by publish-docs.yml, so it cannot host the file
either. latest.json therefore gets its own orphan branch, read over the
raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather
than by switching branches in the checkout -- doing that would have left
the following steps standing on a one-commit history, and the next step
but one runs release:notes against the real commit range.
Also fixed, all of it release-integrity:
- "appimage" is in bundle.targets. The release notes have advertised an
AppImage for months; tauri.conf.json never built one, the artifact
step globbed for *.AppImage, found nothing, and said nothing. The
step now fails instead.
- The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A
manifest referencing a signature that was never uploaded fails only
on the user's machine, so the manifest step also refuses to write an
entry with an empty signature.
- Release notes are generated by release:notes from the traceability
graph, which is what CLAUDE.md has asked for all along, instead of a
fixed heredoc that said "see CHANGELOG.md for detailed changes" and
linked "GitHub Issues" on a Gitea-hosted project.
- The notes tell users how to verify a download with SHA256SUMS.
Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version
comparison and the platform decision, including that a pre-release does
not offer itself as an upgrade to the matching release).
Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
|
||
|
|
f6653e6a8b |
ci(security): add a supply-chain gate, checksums and an SBOM
The project shipped signed Android builds and unsigned desktop binaries
with no vulnerability scanning of any kind. Nothing checked the ~500
crate Rust graph or the JS packages against an advisory feed, and nothing
checked that what we redistribute inside an MIT bundle permits it.
The first cargo-deny run found eight vulnerabilities and one
unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and
rand -- every one of them closed by a `cargo update` nobody had a reason
to run. That update is in this commit; 740 Rust tests and clippy
-D warnings pass on the new lockfile.
Two structural fixes matter as much as the gate itself:
- deny.toml scopes the graph to the targets we actually ship. Without
it the Apple targets pull in plist -> quick-xml and report two DoS
advisories against a crate that is in no binary we release. Ignoring
those by ID would silence them everywhere, including where they
would matter; scoping makes them correctly absent.
- libmpv is pinned by rev instead of branch = "master". A branch means
the revision is whatever Cargo.lock happens to hold and any
`cargo update` silently substitutes new upstream code -- in the one
dependency that is not from crates.io and that links a C library
into the player. The rev is the commit already locked, so this pins
current behaviour rather than changing it.
Licence findings are recorded rather than waved through. libmpv and
libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the
system library; deny.toml carries the two obligations that follow (keep
the linkage dynamic, ship libmpv's licence text with any bundle carrying
the .so). MPL-2.0 crates are file-level copyleft and fine unmodified.
Releases now publish SHA256SUMS (verified in-job with `sha256sum -c`
before upload) and a CycloneDX SBOM for both halves, so "does this
release contain <vulnerable crate>?" has an answer that is not "rebuild
the tag and re-resolve it".
Workflows pin jellytau-builder:2026.08 instead of :latest. While every
job said :latest, rebuilding the image changed what every build compiled
against, including rebuilds of old release tags.
Also folded in, because both were the same class of problem:
- publish-docs.yml downloaded mdBook from GitHub releases into
/usr/local/bin at job time -- a toolchain install in CI, which
CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN
at publish time. It is in the builder image now.
- extract-traces.ts only ever read .ts/.svelte/.rs, so every
requirement implemented by *configuration* was invisible to the
matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry
TRACES comments nothing read, and each counted as uncovered while
being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves
to 89 accordingly. CI workflows stay excluded and there is a test
saying why: traceability-check.yml quotes "a TRACES: comment" beside
deliberately-undefined example IDs, which the extractor would read
as real traces and then fail its own dangling-ID check.
Supply-chain requirement is DR-216.
🔴 The builder image must be rebuilt and pushed
(scripts/build-builder-image.sh 2026.08) before this reaches master --
the workflows now name a tag and tools that do not exist in the registry
yet.
|
||
|
|
32043a2152 |
docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning. |
||
|
|
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. |
||
|
|
20e2331560 |
chore(release): 0.9.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m43s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 17m59s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
|
||
|
|
8fbf4d92cb |
ci: match release bundles by extension, and ship the rpm
Renaming the app to JellyTau renamed its bundles, and the release job globbed `bundle/deb/jellytau_*.deb`. The copy was wrapped in `if [ -f ... ]`, so the rename would have dropped the .deb from the release silently — a green build producing an incomplete release. Matching by extension removes the coupling between the product name and the pipeline, and an empty dist/linux now fails the job instead of passing quietly. That `if [ -f "dir/"*.ext ]` guard was also wrong on its own terms: with more than one match, test gets extra arguments and returns false. Found while verifying the rename: the rpm has been built by every release since deb+rpm became the bundle targets, and never copied, published or documented. It ships now. Also declares the package rename. Tauri kebab-cases productName into the Debian package name, so "JellyTau" produces `jelly-tau` — a different package from the `jellytau` earlier releases installed, which would have put a second copy alongside the old one. deb now declares Replaces/Conflicts/Provides and rpm Obsoletes/Provides, verified in the built control file. TRACES: | DR-214 |
||
|
|
d32ca13d00 |
chore: give the project its own identity instead of the scaffold's
Cargo.toml still carried `description = "A Tauri App"` and `authors = ["you"]`, package.json's description was empty with no author or repository, and there was no LICENSE file at all despite package.json declaring MIT. The user-visible half matters more. productName was the scaffold's lowercase "jellytau", which is what the Android *release* build shows under its icon and what the deb/rpm/NSIS bundles carry as their display name. It went unnoticed because build.gradle.kts overrides the label to "JellyTau Debug" for the debug build type — the install a developer sees every day was the only correctly-cased one. mainBinaryName pins the executable filename to "jellytau" so build-windows-cross.sh and the Arch PKGBUILD, which both resolve it by name, need no change. strings.xml moves into the canonical android tree rather than being edited in gen/, since sync-android-sources.sh already copies res/values/*.xml — so the fix survives the next regeneration. Bundle metadata (publisher, copyright, category, descriptions, licence) was absent entirely, so the packages shipped with no maintainer or description. The hand-written PKGBUILD and .desktop had all of it; only the generated packaging was wrong. Adds .env.example: three scripts require signing vars from a gitignored .env and .gitignore already whitelists the example, but none existed. TRACES: | DR-214 |
||
|
|
68ca1d585d |
chore: regenerate bindings and the traceability matrix
bindings.ts picks up the library-exclusion commands and types from tauri-specta. The matrix regenerates because validation.ts and its test are gone — the doc link checker caught the stale references, which is the first time that gate has paid for itself on a generated artifact rather than a hand-written link. Also drops exclusions::is_excluded: a wrapper over is_excluded_by that only a test called, while the trait impls hoist the snapshot themselves. The test now calls the same path production does. |
||
|
|
0815445aa7 |
feat(library): exclude chosen folders from music browsing
Replaces a hardcoded filter that dropped anything named "Podcasts" from music results — one user's library layout compiled into the shipped product, keyed on an English literal, applied only at the six call sites someone had remembered. Exclusion is now a user setting stored in Rust and applied at the repository layer's convergence points, so scope is decided once and is the same on every screen. It matches on folder id rather than name: a title is not what an item is, which is why an album legitimately called "Podcasts" used to vanish. Deliberately not filtered: get_item (an id asked for by name was navigated to on purpose, and refusing it would break playback of anything inside a hidden folder), get_downloaded_items (hiding a download would leave the user unable to delete a file whose disk usage they can still see), and the offline cache (an exclusion is a view preference and must be reversible without a re-crawl). Also removes src/lib/utils/validation.ts — six exported validators with no caller outside their own test file, which made the module read as covered input validation while guarding nothing. TRACES: UR-076 | DR-209 | UT-203 |
||
|
|
048c99ebcc |
fix(downloads): allow the deliberate join_absolute_paths lint in a test
The assertion documents that PathBuf::join discards its base when handed an absolute path — which is why confinement has to happen after the join, not instead of it. clippy::join_absolute_paths flags that shape, correctly for production code, so the lint is allowed here rather than the test weakened. Worth recording: this lint would not have caught the original defect. The real join sites pass a variable, and it only fires on a literal. |
||
|
|
f83c7ed1f0 |
fix(downloads): confine download paths to the download root
file_path and target_dir reached PathBuf::join unchecked from the frontend, and mark_download_completed stored a caller-supplied path that is later fed to remove_file. A correct sanitiser already existed — download_item_and_start used it — but download_item is itself a command taking file_path raw, so the guard was simply routed around. It now lives inside download_item, alongside a join-then-confine check modelled on media_server::resolve_path. Sanitising is per path component, not whole-string: the latter would silently turn downloads/x.mp3 into downloads_x.mp3 and relocate every existing download. TRACES: | DR-211 | UT-205 |
||
|
|
b313b61717 |
fix(repository): bind query parameters and encode URL values
Three consistency fixes, each one applying a pattern the same file already used a few lines away: the offline get_items type filter now binds placeholders like search at offline.rs:1786 does, build_get_items_endpoint percent-encodes its values like the Genres block below it does, and player_set_volume clamps NaN and out-of-range input at the command boundary rather than relying on each backend to do it. TRACES: | DR-212 | UT-206 |