Compare commits

..
Author SHA1 Message Date
dtourolle 2c52077b1d chore(release): 0.8.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m47s
Traceability Validation / Check Requirement Traces (push) Successful in 36s
Build & Release / Run Tests (push) Successful in 26m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m2s
Build & Release / Build Linux (push) Successful in 32m23s
Build & Release / Build Windows (push) Successful in 14m59s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 31s
Minor rather than patch: three user-visible behaviour changes — cloud/D2D backup
disabled, the Android TV launcher entry withdrawn, and lockscreen skip scrubbing
rather than advancing during background audio.
2026-08-16 23:59:54 +02:00
dtourolle 6dfc6b259a fix(player): lockscreen skip scrubs instead of advancing in background audio
onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which
always advanced the queue. Correct for music, wrong for a video whose audio is
running through a background-audio handoff (UR-040): pressing skip to re-hear a
line jumped to the next episode instead of scrubbing.

resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and
is_background_audio_active() is the whole test — the handoff exists only for
video, and an episode played through it reports MediaType::Audio, so media type
cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration]
so a skip near either end cannot seek negative or read as EOF and advance.

Routed through the same spawn-then-seek_absolute path as the scrubber, because a
handoff seek re-opens the stream and must not run under the blocking lock
(DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/
REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a
control that scrubs. The remote-volume action block is deliberately untouched:
the handoff never applies to cast sessions, where skip really does mean advance.

Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust
tests pass, clippy 0, coverage 90%.
2026-08-16 23:54:43 +02:00
dtourolle 42e7d86ec4 docs(audit): record device-verification results and the asset-protocol finding
Device pass on HONOR ROD2-W09 (Android 16 / SDK 36) confirms B2, B4, B5, B7 and
finds no CSP violations across a full browsing session.

C2 was aimed at the wrong thing: the asset protocol is not narrowly used but
entirely unused. getCachedImageUrl has no production callers, images arrive as
base64 data URIs from Rust via imageGetUrl, and the device saw zero
asset.localhost requests. Both protocol-asset and the CSP's img-src http:/https:
grant can likely be dropped.
2026-08-16 23:22:47 +02:00
dtourolle 4e451bb534 chore(bindings): regenerate specta output for the new TRACES doc comments
tauri-specta propagates Rust doc comments into bindings.ts as JSDoc, so adding
TRACES comments to command functions changes generated output. Regeneration
happens at build time, so this was left dirty by the branch that added them.
Doc-comment-only: no signature or exported-symbol changes.

Also records the audit corrections made during device verification (B1 mechanism,
B7 re-framing, B8, D3 magnitude).
2026-08-16 23:15:58 +02:00
dtourolle 889289286b merge: clear the clippy backlog and unify lock helpers (D1-warnings, D3)
51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire
types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to
the poison-tolerant helpers - all of them test code; production was already
clean.

Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in
credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME,
which would have cfg'd the constant out of every non-Linux build. Compiles clean
on Linux, so only Windows/macOS CI would have caught it.
2026-08-16 23:06:33 +02:00
dtourolle 8500da1a42 chore(rust): clear the clippy backlog and finish the poison-tolerant lock sweep
`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero.
Most were mechanical — needless borrows, `assert_eq!` against a bool literal,
`vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only
to index — and were applied with `clippy --fix`, then reviewed line by line.
That review caught one auto-fix that was *not* semantically neutral: dropping
the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned
directly above `SERVICE_NAME`, which would have silently cfg'd the constant out
of every non-Linux build. Removed the stray attribute with the import.

Where a lint asked for a risky change rather than a better one, it is suppressed
with a comment saying why:

- `too_many_arguments` on five `#[tauri::command]` handlers and
  `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>`
  injection, and a parameter struct would change the IPC contract and the
  generated TypeScript for no readability gain.
- `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are
  serde + specta wire types emitted a handful of times a second, never bulk
  allocated; boxing would have to stay invisible to the generated bindings while
  every match arm gained a deref.
- `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a
  test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE`
  flag, and the await it spans *is* the critical section. Each `#[tokio::test]`
  gets its own single-threaded runtime, so this is not the production deadlock
  class the lint targets; restructuring would reintroduce the flag race.

Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it
is now `into_media_item`; the five-tuple episode row in the download commands
has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches
`name: "pause"` instead of guarding on it.

Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`,
completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be
in test modules — production code was already clean — so this is consistency
rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose:
those tests deliberately poison a mutex to prove the helpers recover from it.

Pure refactoring: all 698 tests still pass.
2026-08-16 23:05:13 +02:00
dtourolle 88e15e3e12 merge: Android runtime security (B1, B3)
Correct the POST_NOTIFICATIONS mechanism: the lockscreen notification is exempt
because of the MediaSession token, not because it belongs to a foreground
service — FGS notifications are explicitly NOT exempt. So no permission prompt
and no checkSelfPermission gate; instead both notification builders bind the
token once and log loudly if it is ever null, turning a silent failure into a
logcat line. Stop the webview undoing the network security config:
mixedContentMode COMPATIBILITY, allowFileAccess/allowContentAccess false.

Conflict resolution: this branch's DR-198 collided with the Tauri branch's, so
it was renumbered DR-200 (3 TRACES in JellyTauPlaybackService.kt and the UR-006
matrix row updated). DR-199 was uncontested. Pinned counts summed to DR 191 /
total 334; UR-071 takes both DR-198 and DR-199.
2026-08-16 23:03:29 +02:00
dtourolle c9f33ae6a4 merge: restrictive CSP and narrowed asset scope (C1, C2)
Set a CSP with script-src 'self' (Tauri nonces the one inline bootstrap script),
object-src/frame-src 'none', and necessarily-permissive img/media/connect for the
user-supplied Jellyfin origin. Narrow assetProtocol $APPDATA/** -> thumbnails/**,
which is convertFileSrc's only remaining caller.

Conflict resolution: scripts/extract-traces.test.ts pinned counts summed rather
than side-picked — DR-189 and DR-198 were added independently on two branches,
so DR 187 -> 189 and total 330 -> 332. docs/traceability.md regenerated.
2026-08-16 23:01:50 +02:00
dtourolle a93cee9241 merge: stop backing up credentials no key can ever open (B2, B4, B5)
allowBackup=false plus data_extraction_rules covering device-transfer, not just
cloud-backup; treat an undecryptable credential blob as a logout rather than a
hard error; drop the half-declared leanback/TV entries; jvmTarget 1.8 -> 17.
2026-08-16 23:01:05 +02:00
dtourolle 4996727ca9 merge: enforce CI gates the contributor rules already required (D1, A3, A4, D2)
Add cargo fmt --check (strict) and cargo clippy (advisory) to CI, ratchet the
traceability threshold 50 -> 82, add a dangling-ID gate, and fix the
offlineCatalog flake (cold dynamic import, not a timer).
2026-08-16 23:00:58 +02:00
dtourolle e3cdb12967 merge: traceability matrix repair (A1, A2, A4)
Tag the twelve Done-but-untraced requirements, re-scope the stale libmpv IRs
against the backends that actually deliver them, and define the two dangling
IDs (DR-189, UT-188).

Coverage 285/330 (86%) -> 301/331 (91%); IR 19/32 -> 25/32.
2026-08-16 23:00:35 +02:00
dtourolle 2d21f092d5 fix(android): stop the webview undoing the network security config
MainActivity set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with
allowFileAccess/allowContentAccess = true, which is a blanket cleartext opt-in
reached by hand — the exact thing network_security_config.xml exists to prevent
and its own comment warns against. Nothing needed any of the three:

- file:// is never loaded. Cached thumbnails go through convertFileSrc, which
  on Android resolves to http://asset.localhost/... and is answered by wry's
  request interceptor rather than the filesystem; downloaded media goes over the
  loopback HTTP server (DR-137), which exists precisely because the asset/file
  route cannot stream a large file.
- content:// is never loaded. The manifest's FileProvider is for outbound share
  intents, not webview navigation.
- Mixed content never arises. Tauri serves the UI from http://tauri.localhost
  (use_https_scheme defaults false and is not set), and both 127.0.0.1 and
  asset.localhost are loopback/.localhost origins Chromium treats as potentially
  trustworthy. A plain-HTTP remote server would be mixed content, but the network
  security config already rejects it first — so ALWAYS_ALLOW bought nothing.

COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge: the platform
default at targetSdk 21+ is NEVER_ALLOW, so this is still one step looser, and it
keeps passive content working if the analysis missed a path. The two files now
cross-reference each other so the pair cannot drift apart again.

Also records why POST_NOTIFICATIONS is declared but never requested. An audit
read the missing runtime request as a threat to the lockscreen controls; it is
not. A foreground-service notification is explicitly NOT exempt, but a
media-session one is, and the platform predicate (Notification.isMediaNotification)
requires MediaStyle AND a non-null session token. Confirmed on device: appops
POST_NOTIFICATION: ignore with the transport notification live. So no permission
prompt is added and startForeground stays ungated — a guard there would trade a
cosmetic problem for the "did not then call Service.startForeground()" kill.
What is added is the guard matching the real precondition: both builders bind the
token once and log an error if it is ever null, since SystemUI's media carousel
is gated on the same predicate and a token-less notification loses the lockscreen
controls entirely, silently.

TRACES: UR-006, UR-071 | DR-198, DR-199
2026-08-16 22:59:47 +02:00
dtourolle ebf9a99b80 docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES
anywhere in the tree. The features work — the tags were simply never written —
so the matrix over-reported on exactly the requirements a reviewer would most
want to verify. Each is now tagged at the code that actually implements it:

- JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at
  their Jellyfin call sites in repository/online.rs (search, get_item's
  MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE,
  get_person/get_items_by_person), plus the commands that expose them.
- UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the
  MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and
  LockscreenMetadata / update_lockscreen_metadata.
- IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the
  manual AudioFocusRequest listener for video — and at the media-type string
  that chooses between them.
- UR-037 (with DR-042, also untraced) on the video-library poster grid:
  LibraryGrid, MediaCard, and the tv/movies routes.

Resolve contradictory statuses across layers, evidence first:

- IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv.
  MpvBackend is the audio-only backend and overrides neither
  set_subtitle_track nor set_audio_track — the trait's not_implemented()
  default still stands — so UR-020/UR-021 are met by ExoPlayer and by the
  HTML5 <video> path instead. Both IRs are re-scoped to those backends and
  marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs
  follow.
- IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in
  the project and update_lockscreen_metadata is a no-op off Android. UR-006 is
  corrected to Done (Android) rather than the IR being marked Done.
- A note under the IR table records where a UR is met by a different mechanism
  than its IR anticipated.

Define the two dangling IDs the source already referenced: DR-189 (the control
bar never auto-hid on a touchscreen, because its timer was armed only from
onmousemove) and UT-188 (its rule test). The live-denominator assertion in
extract-traces.test.ts moves 187/330 to 188/331 accordingly.

Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
2026-08-16 22:58:55 +02:00
dtourolle 38dd1129e5 feat(security): set a restrictive CSP and scope the asset protocol to thumbnails
`app.security.csp` was `null`, so the webview ran with no Content-Security-Policy
at all: any script that reached the web layer would have inherited the whole IPC
surface. There is no known injection path today (one app-owned `{@html}`, no
`innerHTML`/`eval`), so this is defence in depth rather than a fix for an open
hole.

`script-src 'self'` is the restrictive half — Tauri nonces SvelteKit's inline
bootstrap script at build time, so no `'unsafe-inline'` is needed — together with
`object-src`/`frame-src 'none'` and `base-uri 'self'`. `img-src`/`media-src`/
`connect-src` cannot be restrictive: the Jellyfin origin is typed in by the user
at run time and is routinely plain http on a LAN, so they allow `http:`/`https:`.
That is a wide grant for data, but it still bars `file:`/`filesystem:` and does
not touch script execution. A run-time policy naming the server exactly was
rejected: Tauri derives the header from immutable config when it serves the HTML,
so it would mean rebuilding config and reloading the webview on every server
change. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"`
attributes into markup; `worker-src`/`media-src` keep `blob:` for hls.js's
demuxer worker and its MSE object URL; `ipc:`/`http://ipc.localhost` keeps
`invoke` working. `devCsp` mirrors it with the eval/inline/websocket allowances
Vite's dev server needs.

The asset-protocol scope narrows from `$APPDATA/**` — the storage root holding
the SQLite database and the encrypted-token fallback file — to
`$APPDATA/thumbnails/**`. Since DR-137 moved downloaded media to the loopback
media server, `imageCache` is the only `convertFileSrc` caller left.

Needs manual verification on both platforms: thumbnails, online HLS video and
offline downloaded video cannot be exercised headlessly.
2026-08-16 22:58:53 +02:00
dtourolle 4c9361d020 fix(android): stop backing up credentials no key can ever open
The app's data dir was eligible for Google cloud backup: the manifest set
neither allowBackup nor any extraction rules, so the SQLite catalogue
(library metadata, watch history) and the jellytau_secure_prefs
credential blob were shipped to the user's Google account. Restoring that
is worse than not having it — SecureStorage encrypts under an Android
Keystore key, and Keystore keys are never backed up, so a restored
install gets ciphertext with nothing to open it and fails auth silently
while looking signed in.

Backup and device-to-device transfer are both turned off. allowBackup
="false" covers API 24-30 outright and kills cloud backup on 31+; it does
NOT stop D2D there, so @xml/data_extraction_rules excludes every domain
from both channels. Nothing is lost: the catalogue is a rebuildable
mirror of the Jellyfin server, and watch state lives on the server.

The credential-load path degrades instead of erroring, because a device
can still arrive at undecryptable ciphertext (an older install's backup,
a Keystore key invalidated by a lockscreen change). Both backends now
distinguish "nothing stored" from "stored but unreadable" and answer the
second as the first: CredentialStore::load_credentials_file logs and
returns an empty map rather than CredentialError::Encryption — which
storage_get_access_token was turning into a hard Err and
storage_get_active_session into a warning — and SecureStorage.getCredential
discards the dead blob so it cannot fail every subsequent read. The
result is a login screen rather than a broken session, and the next
successful sign-in rewrites the store.

Also removes the half-declared Android TV support: the manifest offered
LEANBACK_LAUNCHER and the leanback uses-feature with no D-pad focus
model, no TV layouts, and neither of the two declarations Play's TV
validation also requires (touchscreen required="false", android:banner).
That fails review while advertising the app to TV launchers. All four go
back together when a focus pass is actually done.

And raises jvmTarget from 1.8 to 17 under compileSdk 36, with matching
compileOptions — AGP 8.11 already requires a JDK 17 toolchain, so 1.8 was
only capping emitted bytecode. Nothing else in the build assumed 1.8.

TRACES: UR-012 | IR-014
2026-08-16 22:56:32 +02:00
dtourolle b9dab56379 ci: enforce the checks the contributor rules already required
Four gates that were documented but unenforced, plus the flaky test that
made a full-suite run untrustworthy.

Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy`
before every commit for as long as the rule existed, yet neither ran
anywhere in CI — the requirement rested on memory alone. Both now run in
build-and-test.yml and build-release.yml. rustfmt and clippy are already
baked into the builder image, so nothing is installed at job time.
`cargo fmt --all -- --check` is strict immediately (the tree is clean).
Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings`
would fail on unrelated work, so the step carries a TODO to flip the flag
once the backlog clears. A compile error still fails it, so it is not a
no-op.

Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was
86%, so nearly half the matrix could rot before the gate objected.
Ratcheted to 82 with the policy written down — it only ever goes up, and
is never lowered to make a red build pass. The same figure lives in
MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar,
and a test fails if the two drift.

Dangling IDs: a TRACES comment could name any well-formed ID and the
extractor accepted it silently, so typos and renames that missed a call
site passed unnoticed. `bun run traces:validate` cross-checks every
traced ID against the table rows in requirements.md and fails with the
referencing files listed. It spans UT/IT as well, which the coverage
orphan list ignores by design. This currently reports DR-189 and UT-188,
which are being defined separately.

Flaky offlineCatalog test: the first dynamic import of the service paid
~1s to transform its dependency graph, charged to a test body against
vitest's 5s default. Alone it passed; under suite-wide contention it
timed out. The import is now warmed at collection time, so no test is
timing the compiler — the timeout is deliberately unchanged. The store
shim also drops subscribers from module instances discarded by
resetModules, which previously leaked across tests.
2026-08-16 22:51:44 +02:00
dtourolle 73641e192c chore(release): 0.7.0
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m50s
Traceability Validation / Check Requirement Traces (push) Successful in 44s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m50s
Build & Release / Run Tests (push) Successful in 18m46s
Build & Release / Build Linux (push) Successful in 30m52s
Build & Release / Build Windows (push) Successful in 15m13s
Build & Release / Build Android (push) Successful in 31m53s
Build & Release / Create Release (push) Successful in 12s
Version bumped across package.json, tauri.conf.json and Cargo.toml (+ lock),
CHANGELOG entry written from the five commits in the range rather than from the
trace extractor's output — VideoPlayer.svelte alone carries dozens of TRACES, so
the generated draft named most of the app's requirements for a five-commit
release.

DR-188 is retargeted: it recorded the native-video default as waiting on the
background-audio handoff, which is now fixed (DR-196), so it records the
completed flip and the evidence for it instead.

Minor, not patch: the rendering path changes underneath every Android user.
2026-08-16 22:28:05 +02:00
dtourolle be907b4945 fix(home): stop Next Up repeating Continue Watching
Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a
partially-watched episode as its own series' next up — precisely the
episode /Items/Resume already returns. Home's "Next Episode" row and the
TV landing's Next Up row therefore duplicated Continue Watching card for
card.

build_next_up_endpoint now sends EnableResumable=false, and because
servers predating that parameter ignore it, filterInProgressNextUpItems
also drops any next-up entry whose id appears in the resume list. It is
the mirror of DR-089 and sits beside it: presentation-layer de-duplication
over two lists the frontend already holds. The resume filter still reads
its frontier from the unfiltered Next Up list, so pruning in-progress
entries cannot resurrect a stale resume card.

The code changes were swept into 5e8efa25 by a concurrent `git add -A`;
this carries the remainder — DR-197 / JA-036 / UT-190..192, the
renumbering off the DR-196 collision that commit created, the regenerated
matrix, and the requirement-count guard.

TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191, UT-192
2026-08-16 22:18:06 +02:00
dtourolle 3b9a8ad695 test(player): pin the native-video default and the opt-out that must survive it
The default has moved four times, so the risk is not which way it points but
that a flip silently overrides people who chose. The previous reader was
getItem(KEY) === "true", which conflates "never chose" with "chose off" — under
it, flipping the default re-enables the native path for everyone who had
deliberately turned it off. The three cases are pinned separately so that
conflation cannot come back.
2026-08-16 22:16:39 +02:00
dtourolle ab95f5013d feat(player): make native Android video the default
The two defects that were holding the flip back are fixed and verified on a
device, which is the standard this default has been held to since DR-161 shipped
a verified sub-path over an unverified one:

  - returning from background audio restarts the renderer that is actually on
    screen, instead of only ever reloading the <video> element (DR-196)
  - the letterbox bars are painted, instead of retaining whatever was last in
    the framebuffer (DR-194)

Evidence: handoff to audio-only at 69:54 returning to video playing at 70:18,
and clean bars across playback, the control bar and a rotation round-trip.

An explicit stored choice still wins in both directions, so anyone who turned the
flag off keeps it off — hence the null check on the stored value rather than a
bare === "true", which would silently re-enable it for people who opted out.

The Settings copy no longer tells users to leave it off; it now describes the
toggle as the fallback to the built-in web player.

The flag keeps its "experimental" name because it remains a suppressor of Rust's
backend choice, never a promoter: turning it on cannot produce a native backend
where Rust says HTML5.
2026-08-16 22:14:43 +02:00
dtourolle 5e8efa252e fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a
play overlay pinned at 0:00, a seek bar at zero, and a play button that did
nothing. Nothing crashed — the process stayed up and the frontend kept logging —
the transition was simply dropped.

The two render paths resume by different means, and exitBackgroundAudioHandoff
only ever performed one of them. The webview <video> reloads off its stream URL:
an $effect watches it, reinitialises HLS or sets element.src, and canplay drives
the seek and play. ExoPlayer owns no element and nothing watches the URL on its
behalf — native playback is only ever started by an explicit player_play_item
plus adapter load, which the component issues once, from onMount. So reassigning
the URL restarted precisely nothing, and since player_exit_background_audio had
already stopped the handoff's audio player, the backend came back holding no item
at all. That is why the play button was inert: there was nothing loaded to play.

The return now re-issues that pair on the native path, in the same order as the
initial load, carrying the position the audio reached. Subtitle configurations are
reused from the ones resolved at mount — ExoPlayer sideloads them as
MediaItem.SubtitleConfigurations and cannot accept one after prepare().

Which path to take is decided by planHandoffReturn, a pure helper in
backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the
player. It also folds in shouldResumeOnForeground, so a pause taken on the
lockscreen during the handoff still wins over the snapshot captured on the way
out.

Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54,
return restored native video playing at 70:18. Previously the same sequence left
the player idle and black.

The requirements count pin in extract-traces.test.ts moves with the new DR-196.
2026-08-16 22:10:14 +02:00
dtourolle 1285908733 fix(android): paint the letterbox bars, so stale pixels stop surviving in them
Native video left debris in the padding around the video: the "previous frame"
flash on rotation, a ghost copy of the control bar stranded in the top bar, each
new clock digit drawn over the one before it (35:42 with the 1 still showing
through the 2), and the sleep/quality menus leaving their imprint after closing.
One cause under all of it — nothing painted those bars.

The window surface is opaque; the theme is not translucent and dumpsys window
shows no translucency flag. For an opaque surface HWUI deliberately does NOT
clear the damaged region before replaying a frame: it assumes the view hierarchy
covers every pixel it owns. Here that hierarchy is window background → video
TextureView → transparent WebView, and fitSurfaceToScreen sizes the TextureView
to the letterboxed video rect. So the bars were the window background's alone to
paint, and setTransparent(true) cleared it to TRANSPARENT — leaving them painted
by nobody, with whatever was last in the framebuffer surviving there.

The window background now stays opaque black while compositing. It cannot hide
the video: the TextureView is drawn on top of it, and the WebView's own
background is what lets the picture through.

Three previous attempts missed because they aimed at the window's rotation
animation and at TextureView frame-retention — two postOnAnimation hops, an
onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT with
FLAG_FULLSCREEN to make it stick. The pixels were never the animation's, which is
also why the artefact reproduces standing still, with no rotation involved. Those
are removed. The alpha-hiding among them actively made things worse: it blanked
the one view that reliably paints its own rect. FLAG_FULLSCREEN goes too — it
fought edge-to-edge insets for no gain.

Verified on device (HONOR ROD2-W09, Android 16): reproduced with native video on
— ghost control bar in the top bar, doubled clock digit — then absent after the
fix across playback, the control bar and a rotation round-trip.

DR-194 is rewritten to record the real mechanism and marked Done.
2026-08-16 21:51:14 +02:00
dtourolle 8e98e1c37a test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it
renders the real component against the real bindings, which bottom out in the
globally mocked `invoke`. That mock resolves `undefined` for every command, so
any command whose result is *rendered* blows up: the quality picker assigns the
result straight to state and the template then reads `streamingQualities.length`,
which throws on undefined.

It threw asynchronously, outside any test, so the suite reported 4 unhandled
errors while every test still passed — the state vitest warns "might cause false
positive tests". Answering the two rendered commands removes them.

Authored in the main checkout; brought in here and verified: 83 files, 1009
tests, and the unhandled-error count drops from 4 to 0.
2026-08-16 21:20:55 +02:00
dtourolle 440d7a01a9 chore(release): 0.6.0
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Android native video renders a picture, and its transport works.

The path shipped once as audio with no picture and was reverted with the
compositing named as the suspect. It was not the compositing: five independent
defects sat between ExoPlayer and the screen, each able to produce that symptom
on its own — the app shell painting over the surface through a CSS rule aimed at
an attribute nothing set, a poster card with no way to lift on a path that
renders no <video>, JS bridges racing the page load and losing permanently, a
SurfaceView that was never detached, and a frontend that told Rust a webview
element was playing when none existed, so every play/pause intent was aimed at
something that was not there.

Native video stays opt-in. Turning it on surfaced a further unverified path —
the background-audio return is written only for the webview element — and
rotation still needs device confirmation.

Minor rather than patch: the player's touch behaviour changes for everyone (the
control bar now auto-hides on touchscreens, and the system bars go away with the
player), not only for those who opt into native video.
2026-08-16 21:14:08 +02:00
dtourolle dccb5f53dd fix(android): stop the rotation cross-fade replaying the old video frame
Rotating with native video on shows the previous frame flashing in what become
the letterbox bars. It reads as a TextureView artefact — the view retains its
last frame, so between the rotation and fitSurfaceToScreen() landing that frame
sits at the old size — and two fixes were built on that reading:

  1. reveal after two postOnAnimation hops. An animation frame is not a video
     frame; at 24fps the next decoded frame can be several vsyncs away.
  2. reveal on onSurfaceTextureUpdated, i.e. when a real frame lands. This meant
     owning the SurfaceTextureListener and handing ExoPlayer the Surface directly
     instead of via setVideoTextureView, which installs its own and leaves us
     blind to frame arrival.

Neither stopped the flash. The mechanism is the WINDOW's rotation animation:
Android cross-fades a screenshot of the old orientation, that screenshot holds
the old video frame at the old size, and nothing at the TextureView level can
reach it. The app cannot pre-empt the screenshot either — onConfigurationChanged
fires after it is taken.

So the animation itself has to go: ROTATION_ANIMATION_JUMPCUT. That was accepted
and silently ignored, and the platform said why out loud —
"VRI[MainActivity]: setLayoutParams: not fullscreen" — because the attribute is
honoured only for a fullscreen window. FLAG_FULLSCREEN is therefore set with it,
scoped to while native compositing is active so the rest of the app keeps its
normal animation. After the change that complaint is gone from logcat.

The frame-arrival reveal is kept: it replaces a fixed-timeout guess with a real
signal, and its timeout is required rather than defensive — a resize while paused
means no new frame is ever coming, and revealing a stale frame beats a
permanently black player.

NOT CONFIRMED FIXED on device. The forced-rotation harness
(settings put system user_rotation) proved unreliable here, and screenrecord
fixes its canvas at start, so a rotation inside a recording never changes frame
dimensions — which defeated two separate attempts to measure this. DR-194 is
recorded as "Needs device verification" rather than Done.
2026-08-16 18:46:15 +02:00
dtourolle c142568230 fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen
tap, from the control bar, and from a direct player_toggle invocation — while
seek and skip kept working. That asymmetry was the whole clue: seek decides in
player_seek_video, transport decides in toggle_playback.

DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is
active and in this state", and toggle_playback/play/pause all route transport to
that element whenever it is set. The player route mirrored element state into it
UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress,
which VideoPlayer calls on a 10-second interval. So on the native path the
frontend re-declared every ten seconds that an element was playing when none
existed, and every transport intent was emitted into the void. It also explains
the flashing: the control bar and the JRay overlay both key off isPlaying, which
was being contradicted on every tick. The mirror now lives in
mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only
place that knows whether an element renders at all. The route cannot tell the
paths apart, which is exactly how it came to lie.

DR-193 hands transport authority back to the native backend when an item loads
into it. Necessary but insufficient alone: the progress interval put the flag
straight back, which is why the first device test after it still failed.

DR-192 presents native video through a TextureView instead of a SurfaceView. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and everything drawn above that hole — here, the
entire Svelte UI — depends on that composition path. The overlay dropped its
incremental damage: the DOM advanced (slider 476 -> 479 across three seconds)
behind a screen showing neither, so the progress bar froze, controls would not
fade and rotation lost the transport UI, while structural DOM changes got
through, which is why the play overlay always appeared to work. It supersedes
DR-191, which forced redraws in a loop and treated the symptom.

DR-194 hides the video view across a resize and reveals it two frames later. A
TextureView retains its last frame, so between a rotation and the re-fit landing
that frame is stretched across the old rect and the previous frame flashes in
what should be the letterbox bars.

Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the
live DOM over the devtools socket: surface tap pauses (position frozen across 12
seconds, overlay raised, transport flipped) and resumes; the control bar does
both. UT-189 drives the real 10-second interval under fake timers — an earlier
version asserted on a freshly mounted player, passed with the guard deleted, and
guarded nothing.

Still open, and deliberately not claimed: DR-192's effect on the overlay repaint
is unverified on device, DR-194's letterbox reset is untested, and the native
default (DR-188) stays off pending DR-190, the background-audio return.
2026-08-16 18:03:22 +02:00
dtourolle 95129d04a3 fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. Invisible three
ways over: the CSS is valid, the selector is plausible, and a rule matching
nothing looks exactly like a rule matching something already transparent.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
2026-08-16 15:28:10 +02:00
dtourolle f0f98feae8 fix(player): strip the burn-in the server puts back into its own transcode URL
The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo
answers with a TranscodingUrl we played that URL verbatim — and the server
built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends
SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the
burn-in we had just declined came straight back through the URL, turning a
remux into a full frame-by-frame re-encode.

Live TV never declined it at all: open_live_stream sent no index, so the
server applied the channel's default track, and broadcast subtitles are DVB
bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight.

without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod,
SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server
built — matched case-insensitively, as Jellyfin binds query keys — and
re-appends the -1 sentinel, because an absent index is not "none", it is
"you choose". Applied at both adoption points, plus the sentinel in the
live-stream negotiation body and its fallback URL.
2026-08-16 14:56:40 +02:00
dtourolle d9e1e256e9 fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a
trailing space from a soft keyboard reached the server verbatim. Jellyfin
reports that as an unknown user, which surfaces as a 401 indistinguishable
from a wrong password — the user is certain of their credentials and the app
insists otherwise.

Normalising in AuthManager rather than the form keeps it on the path every
caller uses, alongside normalize_url. Only surrounding whitespace is
stripped; interior spaces are legal in Jellyfin usernames.
2026-08-16 11:31:34 +02:00
dtourolle 42868fc2e6 feat(login): reveal-password toggle, and stop the keyboard editing credentials
Add an eye/eye-off button inside the password field so a typed password can
be checked against what was intended — the difference between "wrong
password" and "wrong keyboard" was previously invisible.

`bind:value` is not allowed alongside a dynamic `type`, so the field is wired
manually via value/oninput; unlike branching on two separate inputs, this
keeps focus and caret position when the toggle is pressed.

Both fields also get autocapitalize/autocorrect/spellcheck off and proper
autocomplete hints. The Android soft keyboard was free to capitalise or
autocorrect the username, which silently changes a credential the user
believes they typed correctly.
2026-08-16 11:31:27 +02:00
dtourolle c0c6c5023e fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00
dtourolle 521acc75fd build(android): add a side-by-side release build for validating R8
R8 has broken release APKs here before by stripping the JNI-loaded player
and security classes, and the only way to reproduce that was to build with
the real signing key and clobber the install you actually use.

`./scripts/build-and-deploy.sh release --device --debug` now builds a
fully minified release APK — exactly what ships — into the .debug
applicationId slot, signed with the local debug keystore:

  release            com.dtourolle.jellytau        0.5.5
  release --debug    com.dtourolle.jellytau.debug  0.5.5-debug-release
  debug              com.dtourolle.jellytau.debug  0.5.5-debug

It shares the applicationId *and* the signature with the plain debug
build, so the two replace each other cleanly rather than colliding, and
the versionName suffix says which is currently installed. No real key is
needed, so the side-by-side path deliberately skips
write-keystore-properties.sh.

The flag reaches Gradle as JT_SIDE_BY_SIDE=1. CI never sets it, and the
release manifest merges byte-identical without it — verified both ways
through processUniversalReleaseMainManifest.

deploy-android.sh and build-and-deploy.sh learned the flag too, since the
APK path is unchanged but the package to launch is not.
2026-08-16 10:42:59 +02:00
dtourolle 886cbcb29a docs(changelog): backfill every release, and date each fixed defect
CHANGELOG.md stopped at v0.5.0 and had gaps below it. Every tag from
v0.0.1 to v0.5.5 now has an entry, written from the commit bodies rather
than the subjects. Entries before v0.1.2 are shorter and marked as
reconstructed after the fact -- the commit messages of that era ("many
changes", "Playback fix") do not record causes.

docs/defect-windows.md is new: for each fixed defect, the releases it was
actually present in, with the evidence for the dating recorded per row so
a row can be disputed. Dated with `git log -S` on the defective token, not
by blaming the lines a fix removed -- that reliably lands on whatever last
touched the adjacent lines rather than on the defect's origin, and was
used only to shortlist.

Twelve defects date to the v0.0.1 proof of concept and shipped for seven
to eight weeks. They are not regressions but original assumptions nothing
exercised, four of them outright latent: the videoBitrate casing was
harmless until a quality picker existed to select against, and the
unconditional Range header was inert until that fix made transcoded
downloads actually transcode -- so DR-170's code dates to v0.0.1 while its
corruption window is the single release v0.5.1.

Three others are plumbing built and never connected: get_next_up_episodes
accepted a series_id with no caller until v0.3.0, the sync queue ran with
neither producer wired, and both watched-state backend halves sat unused.
No automated check sees these; the code is present, tested and reachable
in principle.

Also corrects the v0.5.5 entry. fa7cb6e9 and dcf08f30 are the same diff
off the same parent -- a local commit and its Gitea PR-merge twin -- and a
merge chain pulled the local one into master during v0.5.5. git log
v0.5.4..v0.5.5 therefore lists an autoplay fix that changed no file in the
release; nextEpisodeService.ts is byte-identical across the tag boundary.
That fix shipped in v0.0.2 and has not regressed. It is the one case where
reading the changelog off commit subjects would have produced a false
entry.

scripts/build-android.sh and src-tauri/src/repository/online.rs are also
modified in this tree by a concurrent session and are deliberately left
uncommitted.
2026-08-16 10:40:33 +02:00
dtourolle 2cc39cd7fd build(android): install the debug build alongside release as its own app
Testing a debug build meant uninstalling the real one first: same
applicationId signed with a different key is INSTALL_FAILED_UPDATE_
INCOMPATIBLE, so every experiment cost the app's settings, credentials
and offline cache.

The debug build type now carries applicationIdSuffix ".debug" and
versionNameSuffix "-debug", so it installs as com.dtourolle.jellytau.debug
("JellyTau Debug", 0.5.5-debug) with its own data directory — two
independent apps on one device.

Only the *application* id is suffixed. Kotlin classes stay in the
`namespace` package com.dtourolle.jellytau, so the JNI loadClass lookups
in player/android/mod.rs, the manifest <service> entry and the R8 keep
rules are untouched, and the FileProvider authority was already
${applicationId}-relative. Launcher names come from the appLabel /
activityLabel manifestPlaceholders rather than resValue, which would
collide with Tauri's generated strings.xml; release resolves them back to
@string/app_name and merges byte-identical.

deploy-android.sh reports the target package and explains an
UPDATE_INCOMPATIBLE failure instead of leaving it raw; logcat.sh takes a
debug|release argument (it was filtering on com.jellytau.app, a package
that has never existed) and attaches by pid when the app is running.

Verified: aapt2 badging on the built APK reports
com.dtourolle.jellytau.debug / 0.5.5-debug / "JellyTau Debug", and the
release manifest merge is unchanged.
2026-08-16 10:36:48 +02:00
dtourolle e457a9884c chore(release): 0.5.5 2026-08-16 10:23:38 +02:00
dtourolle de1c13e72f fix(player,reporting): report real positions, and count an audio-only episode as watched
Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.

The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.

- absolute_position(): the maximum of the backend's reading, the last position
  webview media reported, and the handoff base. Exact rather than heuristic —
  at most one term is ever meaningful, and the base is a floor the stream
  cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
  Jellyfin stores the reported position as the resume point, so sending one
  only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
  throttler it already shared with the native audio path.
  /Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
  so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
  suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
  seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
  downloaded handoff's absolute seek through the stream rebuild, which refuses
  a non-remote source outright.

Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.

TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
        UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
2026-08-16 10:23:00 +02:00
dtourolle 5096c01960 fix(player): restore the subtitle sidecar work dropped by the previous commit
The previous commit was assembled from a tree read before 13264e22 landed,
so committing it reverted that commit's changes: the image-based subtitle
filtering in device_profile/types, subtitleTracks and its tests, the
regenerated bindings, and the VideoPlayer menu wiring.

Nothing was lost — the working tree held both changes throughout. This
restores those files to the merged state, leaving both the subtitle fix and
the play-session fix in place.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 10:20:22 +02:00
dtourolle 2d67b0e4f5 fix(player): give every transcode its own play session, and stop the one it replaces
Switching bitrate mid-film stalled playback. The server served the new
playlist and then rejected its segments: 400 on hls1/main/0.ts, six times
over 25 seconds, never recovering, while the UI logged "Streaming quality
changed" as if nothing were wrong.

Jellyfin keys a transcode job by device and play session. Every stream URL
this app built carried the same hardcoded DeviceId and no PlaySessionId at
all, so the second stream for an item was indistinguishable from the first
and nothing ever stopped the old ffmpeg. Re-opening a stream is not rare —
a quality switch, a transcoded seek and an audio-track switch all do it.
Replayed against the server, a second stream opened for a live job's item
alternates per attempt between serving bytes and 400ing, which is why it
read as flaky rather than broken.

begin_video_play_session mints a session id per open and reports the one it
supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings,
un-retried — a slow stop must not delay playback) before returning. Putting
it in the builder rather than in each caller covers every re-open path by
construction. adopt_video_play_session takes ownership of the job the server
starts itself when PlaybackInfo answers with a TranscodingUrl: without it the
first switch on a stream has nothing to stop and collides with what is
playing.

Two client faults made the same incident worse and go with it:

- The fatal-HLS-error handler added the transcode seek offset to a position
  that already included it. Past roughly the halfway mark of a film the
  doubled value cleared the "near end" threshold, so any transient network
  error was reported as end-of-stream and autoplay skipped to the next item
  — precisely when a quality switch had just made the offset large. The
  decision now lives in hlsRecovery.ts, against the absolute position.
- The HTML5 reload primitive resolved on its own canplay timeout, so a
  reload the server never served reported success. The picker showed a
  quality that was not playing and the caller had nothing to revert.

TRACES: UR-074, UR-004 | DR-177 | UT-173, UT-174, UT-175
2026-08-16 09:47:27 +02:00
dtourolle 13264e225b fix(player): never let the server burn a subtitle in, and never offer one we cannot draw
Reported as "subtitles are shown even when off", and no toggle in the app
cleared them — because they were not the app's subtitles at all. The server was
painting them into the video.

`PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the
server then honours the source's own default/forced flag. On the reported
episode that default is a PGS track — a bitmap, which cannot go out as a
sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it
onto every frame. Confirmed against the live server, which answered the same
PlaybackInfo request two ways: with the index omitted it returned
`SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a
`SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried
`[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream
at all. The cost landed on the video, not the subtitle: burn-in rules out
remuxing, so a stream that only needed its audio transcoded was re-encoded frame
by frame.

Three parts:

- The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text
  format we can render (srt/subrip/ass/ssa/vtt) as `External`.
- The stream URL says the same thing, because the negotiation is not what opens
  most streams: a quality switch, a transcoded seek and an audio-track switch
  each rebuild the URL on their own, and an omitted index there lets the server
  pick the default track back up out of whatever session state it still holds.
- The picker offers only subtitles the app can actually draw. Each subtitle
  stream now crosses the boundary carrying `supports_external_delivery`, decided
  in Rust where the codec vocabulary belongs, and `None` for anything that is
  not a subtitle so a `false` cannot be misread as a verdict.
  `subtitleStreamsOf()` drops the rejected ones — and since that one function
  feeds the menu, the `<track>` children and the native play request alike, a
  bitmap track disappears from all three without its URL ever being fetched.
  Only an explicit "no" hides a track; a stream carrying no verdict behaves
  exactly as before.

Nothing is lost by refusing burn-in: the app already fetches the text tracks and
draws them itself (UR-020), so the server's composited copy was always
redundant. Image-based tracks are consequently not offered, which is honest
rather than a regression — the renderer cannot composite a bitmap, and the old
behaviour paid for them by making the whole stream unwatchable.

Tests were written first and observed failing: the Rust one would not compile
against a field that did not exist, and the frontend one resolved a URL for the
PGS track it was supposed to drop.

Carries with it the in-flight per-stream `PlaySessionId` work in online.rs,
whose hunks sit inside the same request builder and could not be separated from
these.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:47:09 +02:00
dtourolle 041969f446 fix(player): stop the server burning subtitles into the picture
A transcoded episode stalled every few seconds and seeking took five to
nine seconds to produce a frame. Neither was a seek bug: both seeks in the
capture landed correctly. The stream itself could not keep up.

The episode was HEVC video, E-AC-3 audio, and a PGSSUB subtitle track.
Only the audio needed transcoding — the device profile supports HEVC and
the server would have remuxed the video untouched. But the PlaybackInfo
request omitted SubtitleStreamIndex, and omitting it does not mean "no
subtitles": the server then honours the source's default/forced flag and
picks a track itself. It picked the PGS one. PGS is a bitmap, and the
profile advertised only srt/vtt as External, so it could not go out as a
sidecar — leaving SubtitleMethod=Encode, burn-in.

Burn-in is a video cost, not a subtitle cost. Compositing rules out
remuxing, so the whole HEVC stream was re-encoded to h264 frame by frame.
The server could not sustain that in real time: the buffer never grew past
one segment and playback ran waiting -> HLS error -> canplay -> three
seconds of picture, indefinitely, while each seek restarted the encoder
from scratch. TranscodeReasons named it — SubtitleCodecNotSupported — but
nothing in the log connected that to the stall, so the diagnostic now says
which track it is declining and why.

Ask for SubtitleStreamIndex=-1 explicitly, and advertise every text format
we can render (srt/subrip/ass/ssa/vtt) as External so a subtitle can only
ever arrive as a sidecar. Nothing is lost: the app already fetches subtitle
tracks itself and draws them over the video (UR-020), so the server's
composited copy was always redundant. Image-based tracks are consequently
not offered, which is honest rather than a regression — the renderer cannot
composite a bitmap, and the previous behaviour paid for them by making the
stream unwatchable.

The policy lives beside the other device-profile rules in Rust, where it is
testable without a device.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:23:39 +02:00
dtourolle 1a9805f0f3 fix(downloads): queue the whole album, and make every queued track findable offline
An album download put a handful of its tracks on the device while the button
reported the album as downloaded. Two independent gaps, one shared cause.

- `download_album` read its track list from `items WHERE album_id = ?` — the
  local catalog cache. Jellyfin does not return `AlbumId` on every listing
  endpoint, so tracks cached from one of those sit in `items` with a NULL
  `album_id` and are invisible to that query. On the reported database three
  whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially
  linked album queued only the linked subset.
- The frontend then resolved one stream URL per track from its own list and
  paired it with the returned row ids by position. The ids came back in the
  backend's `index_number` order over a different set of rows, so a row could
  be handed another track's URL and any track past the end of the shorter list
  was never started. On Android that loop also stopped wherever the webview was
  suspended.
- `album_id` is what `OfflineRepository::get_items` joins a track to its album
  on, so a track that did download stayed invisible under its album offline —
  the same missing link seen from the other side.

The operation now belongs to Rust end to end:

- `HybridRepository::get_album_tracks` asks the server what the album contains.
  Cache-first `get_items` is right for browsing and wrong for deciding what to
  download; it errors offline so the caller falls back to the ungated local
  catalog, keeping the queue-while-offline flow.
- `queue_album_tracks` writes the album link onto every track it queues, and
  creates an `items` row for tracks the cache has never seen.
- Stream URLs resolve here, through the existing reconnect resolver, now scoped
  to the rows just queued so one album cannot start every unrelated pending row.
  Only the album id crosses the IPC boundary.
- `album_file_names` gives each track its own file. A title repeated inside one
  album (deluxe edition, two discs) mapped to one path, so those downloads
  overwrote each other.

Re-tapping download on a broken album heals it: missing tracks are queued and
the tracks already on disk get their link.

`download_series`/`download_season` still derive their episode lists from the
cache the same way and want the same treatment.

DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and
check:boundary clean.

Note: this tree is shared with a concurrent session. Only the files above are
committed; docs/traceability.md is left to be regenerated once that work lands.
2026-08-16 09:20:32 +02:00
dtourolle 82b6982d68 fix(player): use a speedometer icon for the streaming quality selector
The bitrate ceiling button reused a cloud-download glyph, which read as a
download action rather than a bandwidth setting.
2026-08-16 08:25:43 +02:00
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00
dtourolle 9858b7cb92 chore(release): 0.5.4
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m37s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
Build & Release / Run Tests (push) Failing after 5m56s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
2026-08-16 00:46:17 +02:00
dtourolle f46d7bf676 fix(player): make native Android video opt-in again — it shipped as audio with no picture
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could
shrink a real video surface. On a device that shipped sound with a blank screen.

The decode path was never at fault. Logcat shows ExoPlayer running and feeding a
live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView
sits behind the WebView, and the step that clears the opaque layers above it
never took effect — `WebView transparent = false` is logged, `= true` never
appears. The video was rendering correctly the whole time, behind an opaque page.

This is precisely the defect the flag existed to contain;
VideoPlayer.scrubRegression.test.ts had already recorded that "the native
SurfaceView has never been visible through the webview". Enabling it by default
shipped a verified decode path on top of an unverified display path.

Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives
PiP from the WebView <video> — and working video outranks PiP showing a native
surface. The flag stays in Settings, now described as incomplete rather than as a
performance win, so anyone helping test it still can.

Fixing the compositing is the prerequisite for trying this default again (DR-172).
2026-08-16 00:42:56 +02:00
dtourolle 74bffea650 Merge branch 'fix/autoplay-issues'
Records ancestry only: all three of its changes are already on master,
content-identical, having been applied by cherry-pick rather than merge —
the reportMediaId snapshot in VideoPlayer, the `?restart=true` hand-off in
nextEpisodeService, and the POSIX-sh rewrite of the traceability CI loop.

The branch is 167 commits behind, so the files it touched conflicted with
their own newer selves; every conflict resolved to master's version. The
resulting tree is byte-identical to the pre-merge tree.
2026-08-16 00:14:25 +02:00
dtourolle 7e1f0e0547 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	docs/traceability.md
2026-08-16 00:06:15 +02:00
dtourolle 99ceeadb83 docs: regenerate the traceability matrix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 5m5s
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 generated matrix had drifted well behind the code — this pass picks up
DR-171/UT-166 along with everything else that had accumulated since it was last
run, which is why the diff is large for a mechanical regeneration.

Coverage 87% (265/303), no orphaned IDs, comfortably above the workflow's 50%
floor. No hand edits: `bun run traces:markdown` output as-is.
2026-08-16 00:04:58 +02:00
dtourolle e015c4c9b1 Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work
that landed on master in parallel: it had already claimed DR-163/DR-164 and
UT-162, so the mosaic layout is now DR-172, the library favourites scope
DR-173, and its composition test UT-167.

Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166,
none of which are defined in requirements.md — that branch defined DR-167..171
instead. Those references are orphaned and want a look; nothing here touches
them.
2026-08-16 00:04:26 +02:00
dtourolle 7387f35c7e docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still
described the pre-flip world and one of them was load-bearing:

- `nativeVideo.ts` labelled the store "Default off" directly above a `load()`
  that returns true when nothing is stored.
- The two PiP comments explained themselves as "what makes PiP work in the
  shipping configuration", which stopped being true when Android started
  shrinking the real ExoPlayer surface. They still describe the Linux path and
  the flag-off case, so they say that instead.
- `video_audio_codecs` justified its narrow codec list with "video does not play
  through ExoPlayer", which is no longer so on Android. The narrow list is still
  right, for a different reason now recorded: the flag is a user setting and a
  download outlives it, so only the intersection holds on both sides of the
  switch. DR-171 carries the same caveat.

No behaviour change.
2026-08-16 00:00:10 +02:00
dtourolle 0861523015 feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three
different shapes — square music covers, 16:9 library backdrops, 2:3 posters —
in grids that pick one box and crop everything to it. The home strip said so
in a comment: it forced `aspect="video"` on music libraries so the row would
line up, which lined it up by cutting the covers down.

Both surfaces are now justified mosaics: rows share one height and each tile is
as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles
until the height needed to fill the container drops to the target, justifies the
row by absorbing the rounding remainder into its widest tile, and deliberately
leaves the last row unstretched so one leftover tile does not inflate into a
banner. The component supplies only what the DOM knows: the measured container
width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on
CachedImage), committed in one debounced batch so the grid does not reshuffle
once per image as artwork lands.

Favourites gain a tile per category beside the library it belongs to, alongside
the existing cross-library entry. Which collection type maps to which category
is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`,
stamped onto every `Library` by a new constructor and carried over as an optional
`favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak
`SearchScope::item_types` was extracted to close. A category shows one tile
however many libraries share it, and a library kind favourites do not carve up
(Live TV, channels, books) gets none.

Also corrects the requirements-count test, which the UR-074 commit left one
behind.

Spec: docs/specs/library-mosaic.md
TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162
2026-08-15 23:57:09 +02:00
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00
dtourolle d49d027020 docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
2026-08-15 16:39:53 +02:00
dtourolle 9c352fdb77 Merge branch 'fix/android-versioncode-floor' 2026-08-15 16:35:27 +02:00
dtourolle dda2ff86a3 feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile is what makes the server refuse
to direct-play a file fatter than the cap, and without it a 30 Mbps
remux is handed over untouched and every URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
2026-08-15 16:34:56 +02:00
dtourolle 8ad3dc5c4f fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from an earlier `minor*1000` scheme.
The `minor*100` formula that replaced it yields only 1502 for that same version,
and 1503 for 0.5.3 — lower than what is already installed, so Android refuses
the update as a downgrade. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.

This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.

Widen the multipliers and raise the floor past 5002:

    code = 10000 + major*1000000 + minor*1000 + patch

    0.0.14 -> 10014    0.5.2 -> 15002    0.6.0 -> 16000
    0.1.0  -> 11000    0.5.3 -> 15003    1.0.0 -> 1010000

Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
2026-08-15 16:31:38 +02:00
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
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 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:47:43 +02:00
dtourolleandClaude Opus 5 8fbc080733 Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost:

- DR-150 path: the Android native (ExoPlayer) surface never applied the
  resume seek, so resume always played from the start on device.
- DR-154: a stop-report the server could not be told about was logged and
  dropped, even though sync_queue and its drain were built and running.
- DR-155: the server's watch position was never mirrored into the local
  user_data row the resume check reads, so resume never crossed devices.

Also carries concurrent fixes merged in from parallel work: download
bitrate, series resume ordering, Recently Added grouping, remote-session
volume handoff, and home library card heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:44 +02:00
dtourolleandClaude Opus 5 ba5fd55204 fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore write-only from this
device's perspective: watch 40 minutes in a browser, open JellyTau, and
it resumed from whatever this device last saw, or offered no resume at
all. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. get_item -- the call the player route
makes -- returned the cached copy on a hit and never consulted the
server, so for an already-cached item the mirror never ran. It now
refreshes in the background on a cache hit via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:20 +02:00
dtourolle fec4b7ae8c Merge branch 'fix/download-bitrate-param' into HEAD 2026-08-12 20:11:12 +02:00
dtourolleandClaude Opus 5 2ca2174cea fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider
with no way back to the device speaker.

Two causes:

1. `player_stop`'s remote branch sent "Stop" to the session and returned
   without touching the playback mode, so the manager stayed in Remote.
   It now drops to Idle, mirroring what the local branch already does.

2. Volume routing was torn down at a single call site
   (`transfer_to_local_inner`), so every *other* exit from remote mode
   leaked the Android VolumeProviderCompat. Routing is now derived from
   the transition inside `set_mode`: entering remote attaches control,
   any exit from remote hands it back to the local media stream. This
   also covers the frontend `disconnect()` path (Remote -> Idle) and the
   local-playback-start paths (Remote -> Local).

Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable
off-device — the real implementation is Android JNI. Tests cover
remote->idle, remote->local, remote->remote (re-arms, never releases),
and that local/idle transitions leave routing untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:09:17 +02:00
dtourolleandClaude Opus 5 0ca2857c3a fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a
14-track album filled the whole row with that one album and buried everything
else. Both code paths that build the row had the same symptom from separate
causes:

- Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each
  new leaf on its own. Send GroupItems=true so the server collapses children
  into the container that was added.
- Offline: the downloaded-items CTE deliberately matches leaves *and* their
  container (right for browsing, wrong here), so a downloaded album returned the
  album plus each of its tracks. Drop a leaf only when its own container is in
  the same result.

Items with no container (movies, standalone tracks) are unaffected in both
paths. The online URL is extracted into build_latest_items_endpoint so it can be
asserted without an HTTP server, matching build_favorites_endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:57 +02:00
dtourolleandClaude Opus 5 1f32e4040b Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:

  remote: uniform card heights; resume after furthest-watched episode
  local:  Android native-path resume; queued watch-position sync (DR-154)

Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:21:18 +02:00
dtourolleandClaude Opus 5 e4632bb2b2 fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.

HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).

The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.

The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.

Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:18:11 +02:00
dtourolleandClaude Opus 5 2d50744320 fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:51:37 +02:00
dtourolleandClaude Opus 5 9d7cb085e9 fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in
series order. A viewer who skipped the pilot but is three seasons deep
was sent back to S1E1: the gap was a deliberate skip, not the place they
stopped.

This read as flaky rather than consistently wrong because rung 3 only
fires when the server's Next Up (rung 2) yields nothing, and
`resolve_current_episode` swallows that call's errors with
`.unwrap_or_default()`. `HybridRepository::get_next_up_episodes`
delegates unconditionally to the online repo, so any unreachable-server
moment silently degraded to the empty vec — same series, same watch
state, different answer depending on one request's outcome.

Rung 3 now scans the ordered list from the end with `rposition(is_played)`
and returns the episode after the furthest-watched one, falling back to
the previous first-unwatched behaviour when nothing is watched or the
series is finished. Season crossing comes free from the already-flat
series ordering, and `season_rank` keeps specials last so a watched
special cannot mark a show finished.

Tests written first and confirmed failing (S1E1 where S3E4 was
expected), covering the skipped-pilot case, rolling into the next season
past a skipped episode, and the watched-special case. All 17 existing
tests still pass.

Note: cargo test could not run locally (javascriptcoregtk-4.1 /
webkit2gtk-4.1 absent on this host). The pure policy half plus its
verbatim test module were extracted into a standalone crate to get real
red/green; the full crate suite still needs a run on a complete
toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:23:26 +02:00
dtourolleandClaude Opus 5 85bd227714 fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music
library rendered aspect-square (144px tall at w-36) next to video
libraries at aspect-video (81px), leaving the home row ragged.

Add an optional `aspect` prop that overrides the derived ratio, and pass
aspect="video" from the home Libraries strip. Unset, behaviour is
unchanged, so the /library overview grid and the media carousels keep
their per-type ratios. Artwork already uses object-cover, so square music
art crops rather than distorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:13:27 +02:00
dtourolle 1e599627b5 Fix tracability check
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m16s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m9s
2026-06-23 23:05:30 +02:00
dtourolle fa7cb6e908 fix: Autoplay now resets time to zero and ignores trigger if episode already started
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m12s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-23 21:10:50 +02:00
153 changed files with 20444 additions and 5076 deletions
+26
View File
@@ -61,6 +61,32 @@ jobs:
bunx svelte-kit sync bunx svelte-kit sync
bun run test bun run test
# CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit
# for as long as the rule has existed, but nothing in CI checked either,
# so the requirement rested entirely on memory. Both components are baked
# into the builder image (Dockerfile.builder: `rustup component add
# rustfmt clippy`) — nothing is installed at job time.
- name: Check Rust formatting
run: |
cd src-tauri
cargo fmt --all -- --check
# ⚠️ Advisory for now — clippy warnings do NOT fail this job yet.
#
# The tree carries ~51 pre-existing warnings; adding `-D warnings` today
# would paint CI red on unrelated work. A compile *error* still fails the
# step, so this is not a no-op: it stops new breakage and surfaces the
# backlog in every run.
#
# TODO: once the existing warnings are cleared, tighten this to
# cargo clippy --all-targets -- -D warnings
# Flip that flag — do not delete the step. Track progress with
# `cd src-tauri && cargo clippy --all-targets 2>&1 | grep -c '^warning'`.
- name: Run clippy (advisory)
run: |
cd src-tauri
cargo clippy --all-targets
- name: Run Rust tests - name: Run Rust tests
run: | run: |
cd src-tauri cd src-tauri
+16
View File
@@ -54,6 +54,22 @@ jobs:
bun run test --run bun run test --run
continue-on-error: false continue-on-error: false
# Same gate as build-and-test.yml. A release must not ship from a tree
# that would fail the per-commit checks. rustfmt/clippy come from the
# builder image; nothing is installed here.
- name: Check Rust formatting
run: |
cd src-tauri
cargo fmt --all -- --check
continue-on-error: false
# Advisory until the ~51 pre-existing warnings are cleared; see the longer
# note in build-and-test.yml. Tighten both to `-- -D warnings` together.
- name: Run clippy (advisory)
run: |
cd src-tauri
cargo clippy --all-targets
- name: Run Rust tests - name: Run Rust tests
run: bun run test:rust run: bun run test:rust
continue-on-error: false continue-on-error: false
+23 -2
View File
@@ -81,8 +81,20 @@ jobs:
exit 1 exit 1
fi fi
# Check minimum threshold # Minimum coverage. RATCHET POLICY: this number only ever goes UP.
MIN_THRESHOLD=50 #
# It sits a few points under the coverage actually achieved, so a real
# regression trips it. It was 50 while true coverage was 86%, which
# meant nearly half the matrix could rot before CI said a word — a
# gate that cannot fail is not a gate.
#
# When coverage rises durably, raise this to just under the new figure
# (`bun run traces:coverage` prints it). Never lower it to make a red
# build pass — add the missing TRACES comments instead.
#
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
# scripts/extract-traces.test.ts fails if the two drift apart.
MIN_THRESHOLD=82
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)" echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
exit 1 exit 1
@@ -90,6 +102,15 @@ jobs:
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)" echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
# Every ID named by a TRACES comment must be defined as a table row in
# docs/requirements.md. The extractor used to accept any well-formed ID
# silently, so a typo or a rename that missed a call site passed CI
# unnoticed (DR-189 and UT-188 lived in three source files, defined
# nowhere, for months). This covers UT/IT too, which the coverage
# orphan list above deliberately ignores.
- name: Validate requirement IDs
run: bun run traces:validate
- name: Check modified files - name: Check modified files
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
run: | run: |
+924 -7
View File
@@ -6,6 +6,468 @@ Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md). 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.8.0
A security and correctness release, from an audit of the codebase against its own
requirements and against current Android/Tauri practice. Most of it is invisible
in use; three things change behaviour you can see, listed first.
### ✨ Changes
- **The app no longer backs its data up to your Google account.** It never
should have: `allowBackup` was on by default, which sent the library catalogue
and watch history off the device — and the credentials went with it in a form
that could never be read again, because they are encrypted under an Android
Keystore key and Keystore keys are never backed up. Restoring onto a new phone
therefore produced ciphertext with no key: an authentication failure with no
explanation. Backup is now off, for device-to-device transfer as well as cloud
(a separate channel with the identical failure), and an unreadable credential
blob is now treated as "logged out" rather than an error, so the next sign-in
repairs it. (UR-012 → DR-135)
- **The app no longer offers itself as an Android TV app.** (This is about the
app icon on a TV device's home screen — your TV shows library is untouched.)
It advertised a leanback launcher entry without any of what makes a TV app work — no D-pad focus model,
no banner, and a missing touchscreen declaration that fails Play's TV
validation. Launching it on a TV would have landed you in a UI you could not
navigate. It can be re-declared when TV support is actually built.
- **Lockscreen skip scrubs a film instead of leaving it.** While a video's audio
plays in the background, the skip buttons jump 30 seconds forward and 10
seconds back, rather than advancing to the next episode. There is no "next
track" inside a film, and pressing skip to re-hear a line should not eject you
from what you are watching. Music is unchanged: skip still moves through the
queue. (UR-040, UR-006 → DR-201)
### 🔒 Security
- **The webview now runs under a Content-Security-Policy.** It had none, so any
script reaching the web layer inherited the full IPC surface. `script-src` is
now `'self'` with no inline or eval, and plugins and frames are refused
outright. (UR-071 → DR-198)
- **The webview stops undoing the network security config.** It set a blanket
cleartext opt-in by hand, along with file and content access it never used —
defeating the config that exists to block exactly that, and whose own comment
warned against it. (UR-071 → DR-199)
- **The asset protocol no longer reaches the database or the credential store.**
Its scope was the whole app data directory; it is now the one subdirectory it
serves. (UR-012, UR-071 → DR-198)
### 🐛 Fixes
- **A credential store that could not be read is now recoverable.** The decrypt
failure surfaced as a hard error rather than a logged-out state, so the app got
stuck instead of offering the login screen. (UR-012 → DR-135)
### 🔧 Internal
- CI now enforces the checks the contributor rules already required —
`cargo fmt --check` and clippy — neither of which had ever run there. The
traceability gate was also raised from 50% to 82%, a floor low enough that half
the matrix could rot before it fired, and a new check fails the build on a
requirement ID that no longer exists.
- Twelve requirements marked "Done" carried no implementation trace at all;
they are now tagged, and stale integration requirements that named a backend
never built have been re-scoped to the ones that actually deliver them.
Coverage moved 86% → 90%.
- The Rust lint backlog is cleared (51 warnings → 0), and a flaky test that
intermittently reddened CI is fixed — it was paying a cold module-transform
cost inside a test body, not waiting on a timer.
## v0.7.0
### ✨ Changes
- **Native Android video is now the default.** Video decodes on the device's
hardware decoder instead of the built-in web player, which is easier on the
battery and lets picture-in-picture show the video rather than the app. The
default had been held back deliberately since the picture defects were fixed,
because returning from background audio left playback dead on that path; both
blockers below are fixed and verified on a device, which is the standard this
default has been held to since it last shipped early. The Settings toggle
remains, now as the fallback to the web player, and an explicit choice still
wins in both directions — anyone who turned it off keeps it off.
(UR-003, UR-004 → DR-188)
### 🐛 Fixes
- **The letterbox bars stop showing things that are no longer there.** With
native video on, the padding around the picture kept whatever had last been
drawn in it: the previous frame flashing on rotation, a ghost copy of the
control bar stranded at the top of the screen, each new clock digit drawn over
the one before it, and the sleep-timer and quality menus leaving their imprint
after closing. One cause under all of it — nothing painted those bars. The
window surface is opaque, and for an opaque surface Android's renderer skips
clearing the damaged region and assumes the view hierarchy covers every pixel;
the video view covers only the letterboxed rect, so the bars were the window
background's alone to paint, and enabling compositing had cleared that
background to transparent. Three earlier attempts missed because they aimed at
the window's rotation animation and at video-frame retention — which is also
why the artefact reproduced standing still, with no rotation involved.
(UR-003, UR-066 → DR-194)
- **Returning from background audio brings the picture back.** On the native
path, coming back from the lockscreen left a black screen: a play overlay
pinned at 0:00 and a play button that did nothing. Nothing had crashed — the
transition was simply dropped. The two render paths resume by different means,
and only one of them was performed: the web player reloads from its stream URL,
while the native player owns no element and nothing watches that URL on its
behalf, so it has to be handed the item again explicitly. It now is, at the
position the audio reached. (UR-040, UR-003 → DR-196)
- **Next Up stops repeating what Continue Watching already shows.** The same
episode could occupy both home rows at once. (UR-023 → DR-197)
## v0.6.0
### 🐛 Fixes
- **Android native video actually shows a picture.** It shipped once as *audio
with no picture* and was reverted with the compositing named as the suspect
(DR-172). The compositing was not at fault; five independent defects sat
between ExoPlayer and the screen, each able to produce that symptom alone. The
app shell painted over the video surface through a CSS rule targeting
`[data-app-shell]`, an attribute no component had ever set in any commit
(DR-185). The poster/title card had no way to lift on a path that renders no
`<video>` element, so a black card covered the surface for the whole session
(DR-182). The JavaScript bridges were installed by a 500 ms tree walk that
raced the page load — and lost permanently when it lost, because the
re-injection guard then declined to retry — so `setTransparent(true)` could
never arrive (DR-183). The `SurfaceView` was never detached, leaking one per
video and leaving picture-in-picture's gate stuck open (DR-184). Verified on a
device: logcat now carries `WebView transparent = true` and
`Marking media ready` with video on screen, the pair the original
investigation went looking for and could not find.
(UR-003, UR-004, UR-041 → DR-182, DR-183, DR-184, DR-185)
- **Play and pause reach the player that is actually rendering.** Transport did
nothing on the native video path — from the on-screen tap, from the control
bar, and from a direct command invocation — while seek and skip kept working,
because those decide elsewhere. Rust routes play/pause to the webview `<video>`
whenever it believes one is active, and the player route mirrored element state
into that belief unconditionally, including from a ten-second progress
interval. So on the native path the frontend re-declared every ten seconds that
an element was playing when none existed, and every intent was emitted at
something that was not there. The mirror now lives where `useHtml5Element` is
known. This also explains the flashing transport controls, since they key off
the play state that was being contradicted on every tick.
(UR-005, UR-003 → DR-193, DR-195)
- **The player's controls hide themselves on a touchscreen.** The auto-hide timer
was armed only from `mousemove`, which a touch device never fires, so the
control bar stayed over the video for the whole film. It is now armed on entry
and on every touch, and pinned open while paused, seeking, or with a menu open.
(UR-003, UR-066 → DR-189)
- **The system bars go away with the player.** Immersive mode had exactly one
caller — the fullscreen button — so opening a video left the status and
navigation bars painted over it until the user pressed a control most never
press. (UR-066, UR-003 → DR-187)
### 🔬 Internal
- Native video presents through a `TextureView` rather than a `SurfaceView`. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and Android's own graphics documentation warns
that overlays do not composite reliably above one. (UR-003, UR-004 → DR-192)
- Native Android video remains **opt-in**, and is not yet the default. Turning it
on surfaced a further unverified path: returning from background audio is
implemented only for the webview element, so playback stays dead on the native
path (DR-190, proposed). Rotation still needs device confirmation (DR-194).
(UR-003 → DR-188)
## v0.5.5
### ✨ Features
- **Library artwork is laid out as a mosaic instead of cropped to one box.** The
library overview and the home shortcut strip showed three different artwork
shapes — square music covers, 16:9 backdrops, 2:3 posters — in grids that pick
one box and crop everything to it; the home strip lined its row up by cutting
the music covers down. Both surfaces now justify rows to a shared height with
each tile as wide as its own artwork, packing from the *decoded* aspect ratio
and committing one debounced batch so the grid does not reshuffle as artwork
lands. The last row is deliberately left unstretched, so one leftover tile does
not inflate into a banner. Favourites also gain a tile per category beside the
library it belongs to — which collection type maps to which category is
Jellyfin vocabulary, so it is derived in Rust rather than rebuilding the exact
leak `SearchScope::item_types` was extracted to close.
(UR-075, UR-067 → DR-163, DR-164)
### 🐛 Fixes
- **The server no longer burns subtitles into the picture.** Reported as
"subtitles are shown even when off", with no toggle in the app clearing them —
because they were never the app's subtitles. `PlaybackInfo` omitted
`SubtitleStreamIndex`, which does not mean "none": the server then honours the
source's own default flag, and on the reported episode that default was a PGS
bitmap track, which cannot go out as a sidecar. So it composited the track onto
every frame. The cost landed on the *video*: burn-in rules out remuxing, so an
HEVC stream that needed only its audio transcoded was re-encoded frame by
frame, which the server could not sustain — playback stalled every few seconds
and seeks took five to nine seconds to draw a frame. The negotiation and the
stream URL now both ask for `-1` and advertise every text format the app can
render as `External`, and the picker offers only subtitles the app can actually
draw, with the codec verdict decided in Rust and carried across the boundary.
Nothing is lost: the app already fetches text tracks and draws them itself.
(UR-020, UR-004 → DR-176)
- **Switching bitrate mid-film no longer stalls playback.** Jellyfin keys a
transcode job by device and play session, but every stream URL carried the same
hardcoded `DeviceId` and no `PlaySessionId` at all — so a second stream for an
item was indistinguishable from the first and nothing ever stopped the old
ffmpeg. The server served the new playlist and then answered 400 for its
segments. Re-opening a stream is not rare: a quality switch, a transcoded seek
and an audio-track switch all do it. Each open now mints a session id and stops
the job it supersedes, in the URL builder so every re-open path is covered by
construction. Two client faults that made the same incident worse go with it:
the fatal-HLS-error handler double-counted the transcode seek offset, so past
roughly halfway through a film any transient network error read as
end-of-stream and autoplay skipped to the next item; and the HTML5 reload
primitive resolved on its own timeout, reporting success for a reload the
server never served. (UR-074, UR-004 → DR-177)
- **Downloading an album gets the whole album.** `download_album` read its track
list from the local catalog cache, but Jellyfin does not return `AlbumId` on
every listing endpoint, so tracks cached from one of those were invisible to
the query — three albums in the reported database had it NULL on every track.
The frontend then resolved stream URLs from its *own* list and paired them with
the returned rows by position, so a row could be handed another track's URL and
anything past the end of the shorter list never started. The same missing link
hid downloaded tracks under their album offline. The operation now belongs to
Rust end to end — the server is asked what the album contains, the album link
is written onto every track queued, URLs resolve in the backend scoped to the
rows just queued, and each track gets its own file so a title repeated across
two discs stops overwriting itself. Re-tapping download on a broken album heals
it. (DR-173)
- **Playback positions reported to Jellyfin are real ones.** Returning to the
foreground before the background-audio stream had started playing handed the
frontend 0.0s, so the episode restarted from the beginning and the stop report
wrote that zero to the server as the resume point. The same blind spot covered
webview-rendered media, whose native position is a permanent 0 — 14 of 14 stop
reports in a 35-minute trace were zeroes, one landing 40s after the frontend
had correctly reported 15:22 for the same episode. Position is now the maximum
of the backend's reading, the last position webview media reported and the
handoff base (at most one is ever meaningful); zero-position stop reports are
withheld, since a zero is never information and only ever destroys a real
resume point; and progress is reported from the controller's own ticks —
`/Sessions/Playing/Progress` had previously been requested zero times in those
35 minutes. A finished audio-only episode is also reported stopped at its
runtime so Jellyfin's 90% rule marks it played, which nothing else could do
once the webview was suspended. (UR-005, UR-025, UR-040, UR-071 → DR-178,
DR-179, DR-180)
- **The streaming quality button uses a speedometer icon**, not the
cloud-download glyph that read as a download action.
<!--
Two commits in this range (fa7cb6e9, 1e599627) are a June 2026 duplicate of the
v0.0.2 autoplay fix — same parent, same diff, a second commit object created by
the Gitea PR merge. A merge chain dragged them into master's history here; they
changed no file in this release. Deliberately not listed.
-->
## v0.5.4
### 🐛 Fixes
- **Native Android video is opt-in again — enabling it by default shipped sound
with a blank screen.** The decode path was never at fault: ExoPlayer ran and
fed a live SurfaceView the whole time, behind an opaque page. The step that
clears the layers above it never took effect — the WebView was logged going
transparent `= false` and never `= true`. This is precisely what the flag
existed to contain, and v0.5.3 had turned it on so picture-in-picture would
have a real surface to shrink. Reverting costs nothing that matters: PiP drives
from the WebView `<video>` (DR-160) and working video outranks PiP showing a
native surface. The flag stays in Settings, described as incomplete rather than
as a performance win. Fixing the compositing is the prerequisite for trying the
default again. (DR-172)
## v0.5.3
### ✨ Features
- **Streaming bandwidth can be capped at a chosen bitrate ceiling.** Video
streams opened at a fixed allowance nobody could change — 20 Mbps on the HLS
URL and in the negotiation, and a device profile that let the server
direct-play a source of any size — so on a metered or slow connection there was
no way to spend less. `StreamingQuality` is a ladder (Original, 20/10/8/4/2/1
Mbps, 720 kbps) where each step bundles the total ceiling, the audio share of
it and the resolution that budget can carry; those are Jellyfin encoding
vocabulary, so they live in Rust and the frontend only names a variant. The cap
reaches the *negotiation*, not just the transcode URL — `max_static_bitrate` is
what makes the server refuse to direct-play a file fatter than the cap, and
without it a 30 Mbps remux is handed over untouched and every downstream
parameter is moot. Settings holds the durable default (persisted, unlike the
rest of VideoSettings — a limit set for a metered connection must not silently
revert on the next launch); the in-player menu is the "this film, this
connection" override, which re-opens the stream and resumes at the current
position. (UR-074 → DR-162)
### 🐛 Fixes
- **Four separate defects behind "downloads are still flaky".** Libraries mixed
their media — cached items carry no link back to their library, so the library
branch matched a clause asserting only that the *library* exists, listing films
under Music and albums under TV; the query deciding which libraries appear
already had the right rule, and the two now share one constant (DR-167). Pause
and resume did nothing: `pause_download` wrote a status and stopped there with
no cancellation anywhere in the stack, so the streaming task ran on and
overwrote the row, and `resume_download` flipped a row to pending without
pumping a queue that is not a poller. A per-download stop flag now really stops
the worker, keeping the `.part` file that resume continues from (DR-168).
Partial files were never reaped, because the writer named its sidecar with
`with_extension("part")``movie.mp4` became `movie.part` — while every
cleanup path deleted `movie.mp4.part` (DR-169). And bitrate downloads corrupted
themselves: a transcode is served chunked and cannot byte-seek, so the server
ignored `Range` and answered 200 with the whole stream while the worker
appended it anyway, concatenating a full copy per retry. The response now
decides — append only on 206, otherwise truncate and start over (DR-170).
- **A downloaded video keeps audio the device can actually decode.** `original`
quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD track came down
untouched and the webview had nothing to play it with. The download URL is also
built against the media source actually chosen rather than the item's default.
(DR-171)
- **A batch of reported UI and playback bugs.** Pages no longer inherit the
previous page's scroll position — the shell keeps its scrollers alive across
navigation by design, so the element never remounts and its `scrollTop` survived
the route change, while SvelteKit restores a window scroll this app never uses;
offsets are now recorded per route and per container, reset going forward and
restored on Back (UR-072 → DR-156). Full-screen video on Android hides the
system bars: `requestFullscreen()` cannot touch the Activity window from inside
a WebView, so the control did nothing visible while the bars stayed painted over
the video (UR-066 → DR-157). A watched toggle appears on the episode row, season
header, series and movie hero and the Episode Focus View — both backend halves
already existed with no caller (UR-073 → DR-158). The background-audio handoff
stops leaking its relative timeline: the correction was applied in two
display-only places while progress reports, the frontend and media3's own seeks
all treated the relative timeline as absolute, each crossing losing exactly the
base (DR-159). And picture-in-picture works on the path that actually plays
video — it had demanded a native ExoPlayer surface, which sat behind a flag
defaulting to off, and now accepts the WebView `<video>` (DR-160).
- **0.5.x can install over v0.5.2 on Android.** v0.5.2 shipped `versionCode` 5002
under an earlier `minor*1000` scheme; the `minor*100` formula that replaced it
yields 1502 for that same version and 1503 for 0.5.3 — lower than what is
already installed, so Android refuses the update as a downgrade, and every
0.5.x release built from that script was un-installable for anyone on v0.5.2.
This is the exact failure the guard was written to prevent; its floor had gone
stale, because the highest code the formula *produces* is not the same as the
highest code in the field. The scheme widens to
`10000 + major*1000000 + minor*1000 + patch`, and the guard test now pins
"clears what shipped" rather than a literal that can go stale again.
### 📋 Documentation
- The traceability matrix is regenerated (87% coverage, 265/303, no orphaned
IDs), and three comments still describing native video as defaulting to off are
corrected — one of them load-bearing, sitting directly above a `load()` that
returned true.
## v0.5.2
### 🔧 Internal
- **Gradle ships in the builder image instead of being downloaded per build.**
The release APK job died at the Gradle wrapper step after the 11-minute Rust
compile had already succeeded, on a socket exception mid-transfer.
`tauri android init` regenerates a wrapper pointing at services.gradle.org, so
every Android job re-downloaded ~130MB — slow on a good day, a hard build
failure when the CDN drops the connection, and a standing violation of the rule
that every build tool already lives in the image. The sync script now repoints
the regenerated wrapper at the local distribution, parsing the version the
wrapper actually requests so a future Tauri bump logs a miss instead of pointing
at a missing file. Dev machines are untouched.
## v0.5.1
### 🐛 Fixes
- **Resume position crosses devices.** The resume check reads the local
`user_data` row and nothing else, but the only path by which server `UserData`
lands in that table mirrored `is_favorite` alone and returned early whenever
that field was absent — exactly the shape of an ordinary watched episode. The
position was write-only from this device's perspective: watch 40 minutes in a
browser, open JellyTau, and it resumed from whatever this device last saw, or
offered no resume at all. The mirror now carries the position under the same
conflict rule, so a local position still waiting to be pushed is never pulled
backwards. Mirroring alone was not enough: `get_item` — the call the player
route makes — returned the cached copy on a hit and never consulted the server,
so for an already-cached item the mirror never ran. It now refreshes in the
background on a cache hit, which is why browsing a season picked up other
devices' state while opening the episode directly did not. (DR-155)
- **A watch position the server could not be told about is queued rather than
lost.** The sync queue and its drain were built, tested and running, but the
stop-report path never fed them — the hybrid repository passed reporting
straight through to the online repository, and on failure the error surfaced to
a frontend `catch` whose own comment read "could queue, but for now just log".
Closing a video while the server was unreachable lost the resume point outright.
The pending row for an item is superseded in place rather than appended to,
since progress reports every 10s would otherwise add a row per tick — the
unbounded queue the drain exists to prevent. Queueing is best-effort and never
fails the command: the local position is already saved. (DR-154)
- **Android's native video path resumes at the saved position.** Two layers each
assumed the other did the seek: the only code acting on `initialPosition` was an
HTML5 `<video>` event handler, and `canplay` never fires where there is no
`<video>` element; the native adapter's own branch merely recorded the number,
claiming the backend seeks internally, which it does not; and the player never
called that branch at all. The frontend therefore believed it had resumed — the
seek bar showed the resume point — while ExoPlayer played from the beginning.
Live streams are excluded, since seeking knocks the HLS window off its live edge.
- **Downloading at a chosen quality honours it.** The download URL builder spelled
the transcode parameters `videoBitrate`/`audioBitrate`, but Jellyfin binds
`videoBitRate`/`audioBitRate` — with a capital R. Query-key binding is
case-insensitive, so this is not a casing preference: the lowercase-r form is a
different token that fails to bind, and the server discards it without error and
stream-copies the source. Picking "480p" produced an original-quality file with
no failure surfaced anywhere, while `maxHeight` and `videoCodec` were unaffected
— which is why the height cap applied and the bitrate cap vanished. The presets
also set `allowVideoStreamCopy=false` to force a real re-encode. The
pre-existing unit tests asserted the broken spellings, so they passed against
broken code. (UR-071 → DR-123)
- **A series resumes after the furthest-watched episode, not at the first gap.** A
viewer who skipped the pilot but is three seasons deep was sent back to S1E1 —
the gap was a deliberate skip, not where they stopped. It read as flaky rather
than consistently wrong because that rung only fires when the server's Next Up
yields nothing, and its errors are swallowed, so any unreachable-server moment
silently degraded to an empty list: same series, same watch state, different
answer depending on one request's outcome. Season crossing comes free from the
already-flat series ordering, and specials stay last so a watched special cannot
mark a show finished.
- **Volume control returns to the local speaker when a remote session stops.**
`player_stop`'s remote branch sent Stop to the session and returned without
touching the playback mode, so the manager stayed in Remote; and volume routing
was torn down at a single call site, so every *other* exit from remote mode
leaked the Android volume provider. Routing is now derived from the transition
itself, covering the frontend disconnect and local-playback-start paths too.
- **A new album appears once in Recently Added, not once per track.** Importing a
14-track album filled the whole row with that one album. Both code paths had the
same symptom from separate causes: online, Jellyfin's `/Items/Latest` defaults
to `GroupItems=false`; offline, the downloaded-items CTE deliberately matches
leaves *and* their container, which is right for browsing and wrong here. Items
with no container are unaffected either way.
- **Uniform card heights in the home Your Libraries row.** Artwork aspect ratio is
derived from the item, so a music library rendered square (144px) next to video
libraries at 16:9 (81px), leaving the row ragged. The per-type ratios elsewhere
are unchanged.
## v0.5.0 ## v0.5.0
### ✨ Features ### ✨ Features
@@ -88,6 +550,74 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
so music is not transcoded needlessly. so music is not transcoded needlessly.
(UR-004 → DR-148) (UR-004 → DR-148)
## v0.4.6
### ✨ Features
- **Downloaded video plays offline.** Four separate defects each stopped it on
their own. A completed download's `file_path` is already absolute — the worker
rewrites it on completion — but the player rooted it a second time and handed
the webview `/data/user/0/app//data/user/0/app/videos/x.mp4`; audio was
unaffected because it resolves the same column through Rust, which is why this
read as a video-only fault (DR-133). The asset protocol was never enabled at
all: `convertFileSrc` rewrites a path to `asset.localhost` unconditionally, but
Tauri only answers that origin when the cargo feature *and* the config are both
present, and neither was — which also silently defeated the cached-thumbnail
path, whose soft fallback to the server copy hid the breakage whenever the
server was reachable (DR-134). Tauri's asset protocol then answers a range-less
request by reading the whole file into memory and only advertises
`Accept-Ranges` from inside its range branch, so the first request never learns
ranges exist and Chromium gave up after ~31s; local media now comes from a
loopback HTTP server streaming bounded 4 MiB chunks, confined by a per-session
token and to the app data directory, because loopback is shared between apps on
Android (DR-137). And release builds set `usesCleartextTraffic=false`, so
Android rejected the request before any I/O — a network-security config now
exempts 127.0.0.1 only, and a remote server must still be HTTPS (DR-138).
Known limitation: a download taken at `original` quality is a byte copy, so it
can be any container — an AVI holding XVID is served correctly and refused by
the webview regardless.
### 🐛 Fixes
- **A video queued from a media card no longer downloads as audio.**
`download_item` never recorded `media_type`, and the reconnect resolver read
that NULL as `'audio'`, so a movie's URL was resolved by the audio builder and
completed as an audio-only transcode. The item's own type now decides, and rows
already downloaded that way are requeued on reconnect — prevention alone leaves
them reading "downloaded" and still unplayable. (DR-135, DR-136)
- **Some videos no longer play with no sound.** Jellyfin's `MediaStream.Index` is
global across every stream in a media source, so index 0 is the video stream on
virtually all files — and `AudioStreamIndex=0` was sent as "the first audio
track" on the HLS transcode URL, the background-audio handoff URL, the
direct-play fallback and the negotiation body, asking the server to use the
video stream as audio. Servers that honour it produce a picture with no sound;
only those that silently correct the index hid it, which is why it surfaced as
"*some* videos have no audio". The parameter is now omitted unless a track was
actually chosen. (DR-140)
- **A multichannel track is no longer direct-played to a two-channel sink.**
`MediaCodecList` answers "can this device decode 5.1", which is not the question
that decides whether anything is audible: a phone decodes AC-3 5.1 happily and
still has two channels to play it out of. The profile carried no
`MaxAudioChannels`, so the server was free to hand over the multichannel track —
silence, or dialogue folded into surround channels that go nowhere. The route's
actual channel count now bounds the profile; no codec is ever removed, so a
device with genuine surround output keeps direct-playing it. (DR-141)
- **Video waits for audio focus instead of rolling silently.** Video manages focus
by hand, and all three outcomes of the request were treated as success —
including `REQUEST_DELAYED`, which means the system is withholding our audio
until it calls back. The picture rolled with no sound, indistinguishable from a
broken stream. (DR-145)
- **The no-audio fallback picks a track the device can decode.** When ExoPlayer
selected no audio track, recovery forced group 0 / track 0 unconditionally — but
the most likely reason nothing was selected is that this very track cannot be
decoded here, so the override reinstated the silence it was meant to fix.
(DR-146)
## v0.4.1 ## v0.4.1
### 🐛 Fixes ### 🐛 Fixes
@@ -168,6 +698,182 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
- **Android system bars and display cutout are handled correctly.** (UR-066) - **Android system bars and display cutout are handled correctly.** (UR-066)
## v0.3.0
### ✨ Features
- **Opening a series lands on the current episode, not season 1.** The viewer was
dumped at the top of season 1, and the Play button played nothing at all: it
resolved the first *season* by SortName and navigated to `/player/<seasonId>`,
which the player route bounced straight back to the library. The backend could
already answer "where is this viewer in this show" — `get_next_up_episodes` had
accepted a `series_id` since it was written and no caller had ever passed one.
`pick_current_episode` now resolves in progress → Next Up → first unwatched →
the premiere, with the third rung serving offline where Next Up is always empty,
and specials sorted after the numbered seasons. Seasons collapse to the current
one, the current episode is badged and scrolled into view, and the hero button
reads `Resume S2E4` / `Play S1E1`. Seasons stop being a destination — a season
URL redirects into the series — and the "More Episodes" strip spans the whole
series, so a finale offers the next premiere instead of dead-ending. Six video
routes collapse to two via `?view=` tabs. Clear-history is wired to Jellyfin's
recursive mark-unplayed, and refuses to run offline rather than diverging state
the next sync would undo. (UR-062, UR-063, UR-064 → DR-101, DR-102, DR-103,
DR-104, DR-105, DR-106, DR-107)
### 🐛 Fixes
- **Re-entering a video no longer opens the audio player.** Closing a
webview-rendered video deliberately emits no "stopped" state — that would break
the autoplay handoff — and the direct-play path does not stop the backend on
unmount, so the controller still reported that item as its loaded media.
Re-entering took the "already playing, just show the UI" shortcut, which returns
before a stream URL is fetched, and the render fell through to the audio
surface. Mostly visible on Android, where video direct-plays; Linux transcodes
and stops the backend on unmount. (DR-100)
## v0.2.9
### 🐛 Fixes
- **A backgrounded audio-only episode advances instead of stalling.** It stopped
at the episode boundary and ExoPlayer parked in `STATE_ENDED`, where any later
play intent — lockscreen, headset, Bluetooth reconnect — replays the ended item,
surfacing as the episode randomly restarting. End-of-playback is dispatched from
two places and they disagreed: the Android JNI callback carried the
background-audio branch but can never reach it, because every load sets
`EndReason::NewTrackLoaded` and nothing clears it, so the first real end consumes
it and the decision is always Stop. The path that actually decides is the
frontend's echo, which had no background-audio case at all and started a
countdown whose advance is a `goto()` that cannot start audio while
backgrounded. Both dispatchers now share one `auto_advance_to_next_episode`.
(UR-040)
## v0.2.8
### 🐛 Fixes
- **The video seek bar works by touch.** Dragging or tapping the progress bar
moved the thumb while playback stayed where it was — two touch-only defects,
which is why the mouse-driven scrub tests never caught either. `handleTouchMove`
kept running for touches the tap guard had already excluded, measuring against
the *previous* gesture's start point, so a seek-bar drag produced a bogus
vertical delta: read as a brightness swipe, it dimmed the screen to the floor
and fired a spurious play/pause correction mid-drag. And the seek was committed
only from `change`, which Android's WebView does not reliably fire for a touch
interaction on a range input — so the thumb moved to the tapped position and no
seek ever ran. (DR-099)
## v0.2.7
### 🐛 Fixes
- **Video stopped pausing itself roughly once a second.** The frontend facade
short-circuited play/pause straight into the adapter, whose `toggle()` decided
play-vs-pause by reading `el.paused` off the DOM — so the Rust controller never
saw the intent and could not serialise competing ones. `el.paused` flips
transiently while an element buffers or settles a seek, so two intents ~150ms
apart read *different* values and performed *opposing* actions, a loop that
needed no further input to sustain itself. On device the element was fully
healthy at every pause (`readyState=4`, not seeking, not buffering, not ended),
which is what ruled out a stall. The root cause was that Rust held no state at
all for webview-rendered media, despite the comment above `report_html5_state`
claiming the controller was the single source of truth. (DR-097)
- **Tap gestures act immediately, with no deferral timer.** Tapping the video
surface pause-looped — it unpaused and bounced back about a second later, while
long-press unpaused fine, which pinned it to the tap path rather than the media
pipeline. The handler deferred the first tap behind a 300ms double-tap window,
but the timer callback cleared its own handle *before* invoking the toggle, and
the click-suppression guard keyed on exactly that handle — so the guard was
already open when Android's synthesized compatibility click arrived. There are
only first and second taps: the first toggles, the second seeks and toggles
back, so a double tap seeks while leaving the play state exactly as it was. A
swipe now undoes the touchstart toggle, keeping brightness swipes from changing
the play state. (UR-061 → DR-092, DR-098)
- **Three follow-on tap defects, each a second click target over the video.**
Pausing renders a full-screen play-overlay button, and Android's synthesized
click arrives 30130ms later — by which time that button exists, so the click
landed on the overlay, which called toggle with no guard and resumed
immediately. Unpausing was unaffected because it removes the overlay: an
asymmetry that pointed straight at it. Then the bottom play/pause button did
nothing, because the gesture listener on the outer container and the button's
own handler both fired and cancelled out. Then the control-surface guard added
to fix *that* killed double-tap-to-seek, since the second tap lands on the
overlay. The overlay is now marked as player surface — visually it *is* the
video — and gesture rules live in pure, unit-tested functions. The suite gained
a test that renders the real component and dispatches real touch events at
whatever element is genuinely on top: the pure unit tests all passed throughout
these four bugs, because each helper behaved exactly as specified and every
defect was in the composition. (DR-098)
- **An HLS stall no longer produces an AbortError storm.** Every interrupted play
attempt was reported as a player error, but while a stream stalls hls.js nudges
the element to recover, cancelling the pending `play()` promise — transient, yet
it hit the error handler roughly once a second for the whole stall and left the
UI stuck reporting paused. The in-flight attempt is now memoised so the UI and
recovery share one call. (DR-096)
- **Seeks are clamped inside the media** to stop an end-of-stream pause loop.
(DR-095)
### 🔧 Internal
- `--device`/`--abi` build only the architecture actually needed. An on-device
test build compiled all four ABIs, throwing three of the four Rust compiles
away, which dominated iteration time against a connected phone.
## v0.2.1
### 🐛 Fixes
- **The traceability gate was dead and reported 158% coverage.** It divided traced
counts by hardcoded literals (UR/39, IR/24, DR/48, JA/3, total 114) that had
fallen out of date as requirements grew to 211 — JA alone printed 800% — so the
50% threshold was mathematically unreachable and the job could not fail.
Coverage could have collapsed to 30% behind a green tick. Real coverage was 86%:
the number was fine, the gate was not. Both sides of the fraction are now
derived from requirements.md, IDs are deduplicated (every UR is listed twice), a
TRACES comment naming a deleted requirement is reported as orphaned rather than
inflating the ratio, and a reading above 100% is a hard error rather than the
condition that hid this. Verified empirically — forcing the threshold to 99%
fails, adding a requirement moves coverage 86%→85%. (DR-093)
- **The search scope→item-type taxonomy moves into Rust.** The spec that diagnosed
this leak became the justification for the boundary rule, the `check:boundary`
tripwire and the spec-review checklist — and the fix itself was never built, so
the rule's own founding violation was still shipping. `SearchScope` now owns the
expansion, resolved once before the cache and server paths diverge so online and
offline cannot filter differently. `All` expands to no filter rather than the
union of the other scopes, which would silently drop People, folders and any
type nobody enumerated. Verified by hashing every `src/` file, adding a type to
the Music scope in Rust, and re-hashing: zero frontend files change — a
criterion that failed before this commit. (UR-049 → DR-063)
- **`check:boundary` passed on the very leak it was written for.** The pattern was
anchored to `includeItemTypes:` at the query site, so assigning the same array to
a named const one indirection away was invisible — through every green CI run.
It now matches an item-type array literal anywhere in `src/`, catching a const, a
Record value and a function return alike, with the deliberate limits kept so
single-type presentation stays legal. The allowlist is capped, so the next
exception forces a conversation rather than a one-line append, and the header now
names what the check still cannot see. (DR-094)
### 🔧 Internal
- Three orphaned traceability scripts are removed. All shared one root cause — an
unscoped `grep -r src-tauri/` walking ~40GB of build artifacts — and two hung
indefinitely while the third reported "Total Requirements: 1" and then printed
"All requirements have implementations!" from an empty result set. They were
salvageable, but read an undocumented second tag convention parallel to
`TRACES:`, and repairing them would have re-established the second source of
truth that let "1 requirement" and "211 requirements" coexist unnoticed.
- Five remediation specs from a design-principles audit of CLAUDE.md and the
architecture docs against the actual code. The principles with a working
automated check all held up; the two that had drifted are exactly the two whose
checks were broken or too narrow.
## v0.2.0 ## v0.2.0
### ✨ Features ### ✨ Features
@@ -220,10 +926,45 @@ were wrong.
What remains unproven is SurfaceView-behind-WebView compositing, now tracked What remains unproven is SurfaceView-behind-WebView compositing, now tracked
by a spec rather than asserted as an upstream blocker. by a spec rather than asserted as an upstream blocker.
<!-- ## v0.1.5
Note: v0.1.3v0.1.5 have no entries here. Their changes are in the git log
and docs/traceability.md. _v0.1.3 and v0.1.4 were never tagged; their work is included here._
-->
### ✨ Features
- **A single tap is deferred so a double tap does not also toggle pause.** A tap
cannot be classified when it lands — it may still turn out to be the first half
of a double tap — so play/pause waits for the 300ms window to close and is
cancelled if a second tap arrives. Forward skip moves from 10s to 30s; back
stays 10s. (Superseded in v0.2.7, where the deferral turned out to race the
WebView's synthesized click.) (UR-005, UR-061 → DR-092)
### 🐛 Fixes
- **Locking the screen no longer kills audio during video playback**, even with
the background-audio toggle armed. `configureWebViewForMedia()` ran from both
the delayed post in `onCreate` and every `onResume`, re-registering the JS
bridges each pass — five times in a 45s session. A WebView binds injected
objects at page-load time, so re-injecting over a live page leaves JS holding a
stale proxy: still truthy, and every method gone. The toggle turned blue and
never reached native, so the handoff never ran. Bridges are now registered
exactly once per WebView, and `setBackgroundAudioEnabled` reports whether native
was actually reached, so a dead bridge can never again masquerade as an armed
toggle. Removing the re-injection then revived a latent conflict it had been
masking — three audio-focus requesters inside one uid, with the grant followed
~45ms later by a loss whose handler paused playback. The WebView already manages
focus for `<video>`, so the redundant bridge is dropped entirely, consistent with
the player-is-authoritative principle. WebView console output is now forwarded to
logcat, which is what made this diagnosable at all. (UR-040 → IR-025, DR-051)
- **An expired sleep timer stops without triggering autoplay.** Stopping the
backend makes the native player fire its ended callback, and the timer thread
cancels the timer first — so by the time the callback inspects it the mode reads
Off, the sleep-timer branch is skipped, and the episode path ran, showing a
next-episode popup right after the user's sleep timer expired. The stop is now
recorded as user-initiated before it reaches the backend, which is the honest
label: via the timer they set rather than the stop button. (UR-023, UR-026 →
DR-029)
## v0.1.2 ## v0.1.2
@@ -260,7 +1001,183 @@ were wrong.
**Linux:** 64-bit, GLIBC 2.29+ **Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+ **Android:** 8.0+
## v0.1.1 and earlier ## v0.1.1
### 🐛 Fixes
- **"More Episodes" is populated for series without season folders.** The strip
collapsed to just the current episode on some series, for two reasons: a series
exposing episodes directly as children rather than under season folders yielded
an empty season fetch, and `isCurrentEpisode` over-matched, because episodes
with no season or episode number compared equal (`undefined === undefined`) and
every one of them looked like the focused episode. Flat children are now grouped
by season number under synthesized headers, and the strip's logic is extracted so
both behaviours are unit-tested. (UR-058 → DR-087)
- **Autoplay advances in background audio mode.** An episode handed off to the
audio-only path is a `MediaType::Audio` item, so autoplay's video-only checks
stopped recognising it as an episode and playback simply ended at the boundary.
Episode identity is now carried through the handoff, and because the frontend's
usual advance is a navigation that is unavailable while the WebView is
suspended, the backend performs it directly — fetching the next episode,
building its audio-only URL and loading it into the native player, preserving
identity so the following boundary advances too. (UR-040, UR-023 → DR-052)
### ✨ Features
- **Skipping an episode marks it watched rather than paused.** Skipping left a
mid-episode resume point behind, so the skipped episode reappeared in Continue
Watching with a partial progress bar — but skipping means "done with this one",
not "stopped here". A one-shot suppression keeps the player's post-navigation
unmount stop report from overwriting the 100% progress with the partial one, and
Continue Watching now drops resume entries superseded by Next Up. (UR-059 →
DR-088, DR-089)
### 📋 Documentation
- CLAUDE.md states the failing-test-first rule explicitly: write a test that
reproduces the bug and watch it fail before applying the fix, and extract buried
logic into a plain `.ts` module so it can be unit-tested. A test written against
already-fixed code can pass for the wrong reason.
## v0.1.0
### ✨ Features
- **Cross-platform desktop packaging**, with a Windows NSIS installer built on tag.
- **A webview audio backend** for platforms without a native one.
- **A graphic equalizer** with presets and custom bands.
- **Home cards distinguish tap from long-press** — tap opens detail, long-press
plays.
### 🐛 Fixes
- Downloaded browse groups by container and loads on large libraries.
## v0.0.18
- The background-audio button shows on all Android video playback.
## v0.0.17
This release carried the largest single body of work before v0.1.0 — the
provider-neutral domain model and the boundary rule that still governs the
frontend.
### ✨ Features
- **A provider-neutral media model.** The frontend was moved off Jellyfin's own
vocabulary in phases: item-type strings give way to a neutral `kind`, Jellyfin
ticks become milliseconds end to end (catalog, then player and reporting),
`primaryImageTag` becomes `imageId`, media streams get a neutral `StreamKind`,
and the user-facing type badge becomes a kind label. This is the work the
frontend/backend boundary rule was written to protect, and the tripwire script
(`check:boundary`) lands here with it.
- **Context-scoped search** with filter chips and group order.
- **A browsable downloaded library** with on-disk usage, and WiFi-only,
network-type-aware download gating.
- **A shared account menu and global app header.**
- **A reworked settings page.**
### 🐛 Fixes
- Library listing is gated to downloaded-only when offline.
### 📋 Documentation
- Specs, requirements, UX flows and traceability for the above, plus the written
boundary rule and spec workflow.
## v0.0.16
### ✨ Features
- **Background-audio handoff for video**, alongside a repository/player refactor.
- **Android picture-in-picture** (and three dead Android config files corrected).
- **An mdBook docs site**, its publish workflow, and the release-notes tooling that
turns TRACES into grouped notes.
### 🐛 Fixes
- Resuming video playback after background-audio-only mode.
## v0.0.15
- Navigation splits up from back; faster startup; a POSIX-sh-compatible CI
`versionCode` step.
## v0.0.14
- Layout and remote-playback fixes.
## v0.0.13
- Layout and search fixes.
## v0.0.12
- Offline mode fixes; the Android build uses the signing key.
## v0.0.11
- Offline mode and layout fixes; the per-commit Android APK build is replaced with
a fast compile check.
## v0.0.9 / v0.0.10
_Both tags point at the same commit._
- **The `PlayerAdapter` contract is introduced**, moving the decision logic into
the shared Rust backend — the origin of the unified player boundary the
architecture docs describe.
- CI APK build fixed; incremental builds enabled.
## v0.0.8
- Android playback fixes.
## v0.0.7
- **JRay support**, including actor mugshots.
- Playback reporting wired up; the duration flash fixed; video hidden from the
audio mini player.
- Android lockscreen and media controls kept in sync with playback.
- Sleep-timer and menu-return fixes.
## v0.0.6
Re-tag of v0.0.5 — no commits between the two.
## v0.0.5
- **Server-side channel plugins and HLS streaming.**
- **JellyLMS zones** can be fused and unfused into synchronized multi-room groups,
addressed by MAC.
## v0.0.4
- **Genre sliders, artist links and navigation utilities.**
- Audio can move between remote players.
## v0.0.3
- **Focused music, TV and movie landing screens**, and a self-draining download
queue.
## v0.0.2
- Autoplay resets time to zero and ignores its trigger if the episode has already
started. (The same defect returns in v0.5.5 — see
[docs/defect-windows.md](docs/defect-windows.md).)
## v0.0.1
First working proof of concept: the Tauri shell, the Rust repository and player
layers, and the initial Svelte frontend.
<!--
Entries for v0.1.1 and earlier were reconstructed from the git history after
the fact, so they are shorter and less specific than later ones — the commit
messages of that era did not record causes the way the current convention does.
-->
Released before this file existed — see the git history and the release notes on
each tag.
+21 -3
View File
@@ -31,6 +31,16 @@ bun run android:dev # build + deploy
bun run android:logs # logcat bun run android:logs # logcat
``` ```
The **debug** build type carries `applicationIdSuffix ".debug"`, so
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
build with its own data dir — never uninstall the release app to test a debug
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
R8-minified *release* build in that same slot, signed with the local debug
keystore, for validating minification without the real key. Only the
applicationId is suffixed; Kotlin classes stay in the `namespace` package
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`. `gitea.tourolle.paris`.
@@ -91,14 +101,22 @@ Tooling:
bun run traces # extract traces (default format) bun run traces # extract traces (default format)
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"' bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown # regenerate docs/traceability.md bun run traces:markdown # regenerate docs/traceability.md
bun run traces:coverage # coverage gate — exits non-zero below the threshold
bun run traces:validate # dangling-ID gate — every traced ID must be defined
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
``` ```
Every ID a `TRACES:` comment names must exist as a table row in
`docs/requirements.md``traces:validate` fails otherwise, so a typo or a
rename that missed a call site can no longer pass silently.
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not **CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
GitHub. `traceability-check.yml` fails the build if coverage drops below GitHub. `traceability-check.yml` fails the build if coverage drops below
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an **82%** (`MIN_THRESHOLD`, a *ratchet* — raise it as coverage climbs, never lower
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and it to make a build pass) or if any traced ID is undefined; `build-and-test.yml`
[docs/traces-quick-ref.md](docs/traces-quick-ref.md). runs frontend + Rust tests, `cargo fmt --check`, an advisory `cargo clippy`, and
an Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md)
and [docs/traces-quick-ref.md](docs/traces-quick-ref.md).
### Traces drive release notes ### Traces drive release notes
+16
View File
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable # Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# Gradle distribution. `tauri android init` regenerates gen/android with a
# wrapper pointing at services.gradle.org, so every Android job would otherwise
# download ~130MB of Gradle at build time — slow, and a hard failure when the
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
# Tauri's generated wrapper requests.
ENV GRADLE_VERSION=8.14.3 \
GRADLE_HOME=/opt/gradle/gradle-8.14.3
RUN mkdir -p /opt/gradle/dist && \
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
"$GRADLE_HOME/bin/gradle" --version
ENV PATH="$GRADLE_HOME/bin:$PATH"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding # Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android # or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
+63
View File
@@ -50,6 +50,68 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
| Certificate Validation | System CA store (configurable for self-signed) | | Certificate Validation | System CA store (configurable for self-signed) |
| Token Transmission | Bearer token in `Authorization` header only | | Token Transmission | Bearer token in `Authorization` header only |
| Token Refresh | Handled by Jellyfin server (long-lived tokens) | | Token Refresh | Handled by Jellyfin server (long-lived tokens) |
| Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority |
| Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together |
## Webview Content Security Policy
`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was
`null` — CSP disabled — which meant any script that reached the web layer
inherited the full IPC surface. Tauri computes the header from this value when it
serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap
script, so `script-src` needs no `'unsafe-inline'`.
```
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:;
worker-src 'self' blob:;
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```
| Directive | Why |
|-----------|-----|
| `default-src 'self'` | Everything not named below is same-origin only. |
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
origin is typed in by the user at run time and is routinely plain `http` on a
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
does not touch `script-src`, which is where an injected origin would actually
hurt. A run-time policy naming the server exactly was considered and rejected:
Tauri derives the header from immutable config at the moment it serves the HTML,
so it would mean rebuilding the config and reloading the webview whenever the
user adds or switches a server, to constrain a destination the user chooses
anyway.
`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src`
and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and
code and drives HMR over a websocket. It applies only to `tauri dev`.
### Asset protocol scope
`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage
root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend:
downloaded media moved to the loopback media server in DR-137, and downloaded
audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**`
grant let the webview read the SQLite database and the encrypted-token fallback
file alongside the thumbnails it actually needs.
If a new feature hands the webview a local file, widen this scope to that
subdirectory specifically; a path outside it resolves to nothing and the webview
reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
## Local Data Protection ## Local Data Protection
@@ -67,3 +129,4 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
3. **Logout Cleanup**: Token deletion from secure storage on logout 3. **Logout Cleanup**: Token deletion from secure storage on logout
4. **No Token Logging**: Tokens are never written to logs or debug output 4. **No Token Logging**: Tokens are never written to logs or debug output
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution 5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
6. **Webview Containment**: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
+532
View File
@@ -0,0 +1,532 @@
# JellyTau Codebase Audit
**Date:** 2026-08-16 · **Version:** v0.6.0 · **Commit:** `be907b49` (master)
A review of the Rust/Svelte/Android codebase against its own requirements matrix
and against current Android and Tauri v2 platform practice. Every finding was
verified by running the project's own tooling or reading the code it points at —
nothing here is inferred from documentation alone.
**Scale:** 55,835 LOC Rust · 50,490 LOC TS/Svelte · 530 requirements · 824 traces
| Severity | Count |
|----------|-------|
| High | 5 |
| Medium | 9 |
| Low | 6 |
| Tests passing | 1,719 |
| Untraced requirements | 86 |
| Traceability coverage | 86% (285/330) |
> **Revisions, 2026-08-16.** Three rankings changed after device testing and
> platform research, all documented in place:
> - **B1 High → Low.** The predicted impact was refuted on a physical Android 16
> device. The residual risk turned out to be a different, narrower one.
> - **B7 Low → Medium, re-framed.** The original reading of predictive back was
> backwards: at targetSdk 36 it is already enabled, not merely un-opted-into.
> - **B8 added (Medium).** Android 16 Local Network Protections versus a
> LAN-hosted Jellyfin server.
> - **D3 Medium → Low.** The "820 unwraps" figure was a measurement error; the
> real number is 19, and none are in command handlers.
> - **B1's stated mechanism was wrong** even though its conclusion held. FGS
> notifications are *not* exempt from `POST_NOTIFICATIONS`; media-session
> notifications are. See B1 — the distinction changes what the fix should be.
>
> Original ranking was 6 High / 8 Medium / 5 Low.
**Verified by running:** `bun run check` · `bun run test` · `cargo test` ·
`cargo clippy --all-targets` · `bun run check:boundary` · `bun run traces:json`
**Device-verified (2026-08-16):** B1 and B2 were checked against a physical HONOR
ROD2-W09 running Android 16 (SDK 36) with the shipped app installed. B2 was
confirmed; B1 was refuted and downgraded.
**Not covered:** the e2e suite (`test:e2e` is not wired into CI and was not run),
Windows and Arch packaging paths, and the docs-site build. B3, C1 and C2 still
need a device/desktop playback pass.
---
## A. Requirements versus code
The traceability matrix is the project's own claim about what is built. Of 530
defined requirement IDs, 86 carry no `TRACES:` tag anywhere in the tree. Most of
those gaps are documentation debt rather than missing features — which is
precisely the problem, because it makes the matrix unreliable as evidence.
### A1 · High · Twelve requirements are marked "Done" but have zero traces
`UR-006` (lockscreen/BLE control), `UR-037` (video library presentation),
`IR-006` (Android MediaSession), `IR-008` (audio focus), `IR-022` (person/cast
API), `IR-024` (home-screen API) and six Jellyfin API requirements (`JA-006`,
`JA-009`, `JA-013`, `JA-014`, `JA-015`, `JA-018`) all claim completion with
nothing pointing at an implementation.
These features demonstrably work — lockscreen control, Next Up, favourites are
all shipped. The code is there; the tags are not. That means the matrix currently
over-reports on exactly the requirements a reviewer would most want to verify,
and a regression in any of them would leave no trace to follow.
**Fix:** Tag the existing implementations. Highest value per keystroke in the
whole audit: six of the twelve are single Jellyfin API call sites.
### A2 · Medium · Requirement statuses contradict each other across layers
`UR-020` (subtitle selection) and `UR-021` (audio track selection) are marked
*Done*, while the integration requirements they decompose into — `IR-018` and
`IR-019`, both libmpv-specific — are still *Planned*. Similarly `IR-005` (MPRIS)
sits at *Planned* under a *Done* `UR-006`.
The likely truth is that these user requirements were satisfied through a
different path than the one originally specified (HTML5 `<video>` and ExoPlayer
rather than libmpv), and the IRs were never re-scoped. Left as-is, the matrix
reads as though shipped features depend on unbuilt integrations.
**Fix:** Re-scope or retire the stale IRs so each Done UR rests on Done IRs.
### A3 · Medium · The traceability gate is set far below actual coverage
`traceability-check.yml` fails only below 50%. Real coverage is well above that,
so the gate cannot catch a coverage regression until roughly half the matrix has
rotted. A gate that can only fire after a catastrophe is not protecting anything.
**Measured coverage: 86% (285/330)** — UR 71/75, IR 19/32, DR 166/187, JA 29/36.
IR is by far the weakest dimension, which corroborates A1.
**Fix applied:** `MIN_THRESHOLD` ratcheted 50 → 82, with the ratchet policy
written into the workflow (only goes up; never lowered to make a red build pass).
The same figure is mirrored as `MIN_COVERAGE_PERCENT` in
`scripts/extract-traces.ts` so local `traces:coverage` gates on the same bar, and
a test parses the workflow YAML and fails if the two drift apart.
### A4 · Low · Two traced IDs do not exist in the requirements document
`DR-189` and `UT-188` are referenced by `TRACES:` comments but are defined
nowhere in `docs/requirements.md`. The extraction tool accepts them silently, so
typos and renames pass unnoticed.
**Fix:** Add a dangling-ID check to the extractor and fail CI on it — cheap, and
it keeps the matrix honest in both directions.
### A5 · Not a gap · The remaining untraced requirements are legitimately unbuilt
`UR-016`, `UR-022` and `UR-070` are Planned or Proposed, and `UR-031`
(crossfade) is explicitly blocked by `DR-034`. Their absence from the trace graph
is correct and needs no action — noted so it does not get swept into the fix list.
---
## B. Android platform practice
The app targets SDK 36 with a minSdk of 24. Several manifest and WebView settings
still reflect an earlier target level.
### B1 · Low · `POST_NOTIFICATIONS` is declared but never requested at runtime
*Downgraded from High. The original ranking was refuted by device testing — the
evidence is below, and it is the reason this finding is now near-trivial.*
The permission appears in the manifest, but there is no `requestPermissions` call
anywhere in the Kotlin, Rust or TypeScript sources, and
`JellyTauPlaybackService.startForeground()` runs with no `checkSelfPermission`
guard. On Android 13+ notification permission defaults to denied.
This was ranked High on the theory that it would suppress the media notification
and with it the lockscreen transport controls (`UR-006`). Testing on an HONOR
ROD2-W09 running **Android 16 (SDK 36)**, with the shipped app installed and
playing, shows otherwise. The permission is genuinely denied:
```
POST_NOTIFICATIONS: granted=false, flags=[USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
appops POST_NOTIFICATION: ignore
```
and the notification is nonetheless live and complete:
```
ServiceRecord{... com.dtourolle.jellytau/.player.JellyTauPlaybackService}
isForeground=true foregroundId=1 types=0x00000002
foregroundNoti=Notification(flags=NO_CLEAR|FOREGROUND_SERVICE
category=transport actions=3 vis=PUBLIC)
```
**`UR-006` is not at risk.** But the *reason* is not the one this audit first
gave, and the correction is load-bearing rather than pedantic.
The first explanation here was "foreground-service notifications are exempt." That
is wrong. Android's own wording is that the permission covers "non-exempt
(**including Foreground Services (FGS)**) notifications", and that users who deny
it see FGS notices "in the Task Manager but [not] in the notification drawer" — an
FGS notification is explicitly *not* exempt. What is exempt is **media-session**
notifications. The platform predicate is `Notification.isMediaNotification()`,
requiring `MediaStyle`/`DecoratedMediaCustomViewStyle` **and** a non-null
`EXTRA_MEDIA_SESSION`; it is byte-identical across API 3336, and
`NotificationManagerService` has no FGS clause in either enforcement site.
Why the difference matters: under the FGS theory, anything the service posts is
safe, and the code needs no care. Under the correct one, the exemption is earned
per-notification by the token — so losing the token loses not just the shade entry
but the lockscreen controls entirely, since SystemUI's media carousel
(`MediaDataProcessor.onNotificationAdded`) gates on the *same* predicate. A
token-less notification never even reaches the notification listener.
**The real risk here is not the permission — it is how narrowly the exemption is
earned.** AOSP's `Notification.isMediaNotification()` grants it only when the
style is `MediaStyle`/`DecoratedMediaCustomViewStyle` **and**
`Notification.EXTRA_MEDIA_SESSION` holds a non-null *platform* session token. If
either is missing while the permission is denied, the notification is **silently
suppressed** — no exception, no log.
JellyTau earns it at two sites, both of which hang it on a null-safe call:
```kotlin
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken) // :273 and :466
```
Ordering currently saves it — `mediaSessionCompat` is assigned in `onCreate`
(:195) and `createBasicNotification()` is only reached from `onStartCommand`
(:251) — and the device test confirms it works. But it is one reordering away
from breaking invisibly, and only for users who denied the permission, which is
a population most developers never test as.
**Fix:** Keep the permission declared — download-service FGS notifications are
*not* covered by the media exemption, and this app has a downloads feature that
may want them. Comment both `setMediaSession` sites to record what earns the
exemption, and log loudly if the token is ever null at build time, converting a
silent failure into a diagnosable one.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt:251`, `:273`, `:466`
### B2 · High · Cloud backup is on by default, and it will break credential restore
The manifest sets neither `android:allowBackup="false"` nor a
`dataExtractionRules`/`fullBackupContent` file, so Android's default applies: the
app's data directory is backed up to the user's Google account. That ships the
SQLite catalogue — library metadata and watch history — off the device.
The credential path makes it worse rather than better. `SecureStorage.kt`
encrypts with AES/GCM under an Android Keystore key, and Keystore keys are never
backed up. A user restoring onto a new phone therefore gets the ciphertext
without the key: undecryptable credentials and a silent authentication failure,
with no code path that recognises the situation.
**Fix applied.** `allowBackup="false"`. Extraction rules that merely excluded the
DB and credential prefs would have left nothing worth backing up: the SQLite
catalogue is a rebuildable mirror of the server and watch state lives server-side,
so there is no user-authored data to preserve.
**A gap this audit missed:** on API 31+, `allowBackup="false"` disables *cloud*
backup but **not device-to-device transfer**, which reproduces the identical
failure — the prefs travel, the Keystore key does not. A
`data_extraction_rules.xml` excluding all five domains from both `<cloud-backup>`
and `<device-transfer>` was added to close it.
**A real bug found while fixing this:** the Rust encrypted-file fallback in
`credentials.rs` propagated a decrypt failure as `CredentialError::Encryption`,
which `storage_get_access_token` turned into a hard `Err` — so an undecryptable
blob was an error state, not a logout. It now logs and returns an empty map, so
the caller sees `NotFound``Ok(None)` → login screen, and the next sign-in
self-heals the file. `SecureStorage.getCredential` on the Kotlin side already
returned null, but could not distinguish "nothing stored" from "unreadable" and
left the dead blob in prefs forever; it now separates the cases and discards it.
Three tests written and watched fail first, per the red→green rule.
### B3 · High · `MIXED_CONTENT_ALWAYS_ALLOW` undoes the network security config
`network_security_config.xml` is careful and well-argued: cleartext blocked
everywhere, exempted only for `127.0.0.1` so the local media server can serve
downloads. Its own comment warns "this must not become a blanket cleartext
opt-in."
But `MainActivity.kt` sets `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW`, which
permits the WebView to load http subresources into an https page from any origin.
Alongside it, `allowFileAccess = true` and `allowContentAccess = true` are both
broader than anything the app needs, since Tauri serves the UI from its own scheme
and media comes from the token-guarded loopback server. These read as leftovers
from before the media server existed.
**Fix:** Drop to `MIXED_CONTENT_COMPATIBILITY_MODE` and set both file and content
access to false, then verify offline video still plays.
**Location:** `src-tauri/android/src/main/java/com/dtourolle/jellytau/MainActivity.kt:504-507`
### B4 · Medium · Android TV is half-declared
The manifest advertises `LEANBACK_LAUNCHER` and a non-required leanback feature,
but omits `<uses-feature android:name="android.hardware.touchscreen"
android:required="false"/>` and an `android:banner`. That combination fails Play's
TV validation, and on a real TV the app would launch into a UI with no D-pad focus
model behind it.
**Fix:** Either commit to TV — add the feature declaration, a banner, and a focus
pass — or remove the leanback category until you do.
### B5 · Medium · `jvmTarget` is pinned to 1.8 under compileSdk 36
The Kotlin target has not moved with the SDK. AGP 8 warns on it, and it locks the
Kotlin sources out of APIs and desugaring behaviour that everything else in the
toolchain assumes.
**Fix:** Move `jvmTarget` and the Java source/target compatibility to 17.
### B6 · Low · Media3 is several minor versions behind
`androidx.media3` is pinned at 1.5.0 across exoplayer, hls, session and common.
Given how much of this app's hard-won behaviour lives in ExoPlayer edge cases —
truncated progressive streams, background audio handoff, HLS resume — staying
current on its bug-fix releases has unusually high value here.
**Fix:** Schedule a Media3 bump with a device pass over the playback regression list.
### B7 · Medium · Predictive back is already on, not merely un-opted-into
*Upgraded from Low, and re-framed — the original framing was backwards.*
The audit first read the absent `enableOnBackInvokedCallback` as the app
*forgoing* the Android 13+ back-gesture preview. That is not what the flag means
at this target level. Predictive back is enabled by default for apps targeting
recent SDKs, and Android 16's own behaviour-change list carries "Migration or
opt-out required for predictive back" — with the opt-out being removed. Targeting
36, JellyTau is already getting predictive back; it simply hasn't been checked
against it.
That matters more than a missing opt-in would, because the app does not use
ordinary Android back. It runs a WebView with its own history model —
`src/lib/utils/navigation.ts` tracks a depth counter, applies a popstate delta,
and falls back to a path when `history.back()` would trap the user, with
`scrollRestore.ts` keying off the same popstate events. That is exactly the kind
of custom back handling predictive back is most likely to disagree with.
**Fix:** This is a device test, not a code change — exercise the back gesture
(including the drag-and-release preview and the cancel) from a library page, a
detail page, the player, and the settings screen, and watch for the depth counter
desynchronising. Only change code if it misbehaves.
Separately and unrelatedly: `JellyTauPlaybackService` is `exported="true"` with a
`MediaSessionService` intent filter — conventional for Media3, but it means any
app on the device can attempt to bind and drive playback. Confirm the session's
`onConnect` callback rejects unknown packages.
### B8 · Medium (forward-looking) · Android 16 Local Network Protections vs a LAN Jellyfin server
*New finding, surfaced while researching B1.*
Android 16's behaviour-change list includes **Local Network Permission**. JellyTau's
entire purpose is reaching a Jellyfin server that, for most users, sits on the
local network — so a permission gate on local-network access is a direct threat to
the app's core function, not a peripheral concern.
Stated carefully, because the timing matters: in Android 16 this is **opt-in for
testing**, not enforced by default, with enforcement signalled for a future
release. Nothing is broken today, and the device test will not surface it. But
this is the rare platform change that could stop the app working at all, and it
is much cheaper to handle before it is mandatory.
**Fix:** Investigate what the permission will require, then test the app against
it with the opt-in flag enabled on the Android 16 device already to hand. Track it
as a release-blocking item for whichever Android version enforces it.
---
## C. Tauri v2 configuration
The capability model here is genuinely well done — see section E. The gaps are in
the two settings that govern what a compromised web layer could reach.
### C1 · High · `"csp": null` contradicts the project's own security convention
`CLAUDE.md` lists "keep the CSP restrictive in `tauri.conf.json`" as a standing
rule; the config disables CSP entirely. With it off, any script that reaches the
web layer inherits the full IPC surface.
The realistic exposure today is low, and worth stating plainly rather than
inflating: the frontend has a single `{@html}` — an app-owned icon in
`GenericGenreBrowser.svelte`, not server data — and no `innerHTML`, `eval` or
`new Function` outside tests. So this is a missing defence rather than an open
hole. But it is the defence that stops the next careless interpolation of a
Jellyfin-supplied string from becoming a full compromise.
**Fix:** Set a CSP permitting `'self'`, `asset.localhost`, `http://127.0.0.1:*`
for media, and the configured Jellyfin origin for images. Expect one or two
iterations against HLS playback.
### C2 · Medium · The asset protocol scope is wider than what it serves
`assetProtocol.scope` is `$APPDATA/**`, which covers the whole app data directory
— the SQLite database and the credential store included — while the protocol only
needs to reach cached thumbnails and downloaded media.
Since `DR-137` introduced the token-guarded loopback media server, the asset
protocol's remaining job may be thumbnails alone, which would make the narrowing
nearly free.
**Fix applied:** scoped to `$APPDATA/thumbnails/**`. Confirmed on device that
`jellytau.db` (8 MB catalogue) and `shared_prefs` sit in the `$APPDATA` root and
are now outside the grant.
**But device testing found the finding was aimed at the wrong thing.** The asset
protocol is not narrowly used — it is **entirely unused at runtime**:
- `getCachedImageUrl` in `imageCache.ts` has **no production callers**. Its only
references are its own test file. `convertFileSrc`'s sole production mention
sits inside that uncalled function, so it never executes.
- The real path is `MediaCard``CachedImage``commands.imageGetUrl()`, which
returns **base64 from Rust**. Every image in the app is a `data:` URI delivered
over IPC.
- Confirmed on device: zero `asset.localhost` requests across a full session of
browsing home, the library list and a poster grid; the thumbnail cache stayed
at 12 files and never grew, because nothing calls `thumbnailSave` either.
Two consequences worth acting on, neither yet done:
1. **The `protocol-asset` Cargo feature and the whole `assetProtocol` config
block can likely be removed**, which retires the attack surface rather than
shrinking it. `imageCache.ts` is dead code and can go with it.
2. **`img-src` in the new CSP can be much tighter.** It currently grants
`http: https:` on the reasoning that thumbnails are fetched direct-from-server
on a cache miss — but they are not; they arrive as data URIs. With no
webview-side server image loads anywhere in `src/`, `img-src 'self' data:
blob:` should suffice. That is a real tightening the CSP work left on the
table because it reasoned from the dead code path.
Both need their own device pass, since a wrong `img-src` blanks every image.
### C3 · Low · Shipped desktop bundles have no update path
The bundle targets deb, rpm and nsis, but `tauri-plugin-updater` is not among the
dependencies. Every desktop user upgrades by manually fetching a new package,
which in practice means a long tail of installs pinned to whatever version they
first downloaded.
**Fix:** Add the updater plugin with a signed release manifest, or document the
manual upgrade path in the README so the omission is at least deliberate.
---
## D. CI and code health
Local discipline in this project is strong and well documented. CI enforces only
part of it, which means the discipline holds exactly as long as every contributor
remembers it.
### D1 · High · CI runs neither `cargo clippy` nor `cargo fmt --check`
`CLAUDE.md` requires both before committing. Neither appears anywhere in
`.gitea/workflows/`. The build-and-test job runs the boundary check, the frontend
tests, the Rust tests and an Android `cargo check` — a good set, with the two lint
gates missing.
Clippy currently reports 51 warnings across the lib and its tests, including
unused imports and a redundant import that a gate would have stopped at the door.
**Fix:** Add both to the test job. Start with `-D warnings` on new code only if
clearing the existing 51 is too large a first step.
### D2 · Medium · A flaky test will intermittently redden CI
`offlineCatalog.test.ts` — "pushes include=true while the server is reachable"
(`UT-068`) — timed out at the 5 s limit during a full-suite run, then passed twice
in isolation taking 1.13 s and 0.61 s.
**Root cause (corrected):** this audit originally attributed it to a real
wall-clock timer. It isn't. The cost is the **first dynamic
`import("./offlineCatalog")`**, which pays to transform the service and its whole
dependency graph (~1072 ms cold) inside a test body, charged against vitest's 5 s
default. Later re-imports after `vi.resetModules()` cost ~30 ms. Under full-suite
contention the cold transform alone crosses the limit.
**Fix applied:** warm the import once at collection time with a top-level
`await import(...)`, so no test is timing the compiler. Slowest test 1072 ms →
129 ms; file total 1170 ms → 238 ms. Timeout deliberately left at the default.
A latent cross-test leak was also fixed alongside it — the store shim's
subscribers were never cleared, so every module instance discarded by
`resetModules()` kept pushing its own visibility value.
**Location:** `src/lib/services/offlineCatalog.test.ts:58`
### D3 · Low · ~~820~~ **19** production `unwrap()`/`expect()` calls
*Downgraded from Medium. This audit substantially overstated the problem, and the
correction is worth recording because the measurement error is instructive.*
The original 820 figure came from grepping for `unwrap()`/`expect()` and filtering
lines containing "test". That does not exclude test *modules* — it only excludes
lines with "test" in them. Scripting the actual `#[cfg(test)]` boundaries gives
**19 real production sites**, not 820. `player/mod.rs`'s 154 hits, for instance,
are *all* past its `#[cfg(test)]` at line 2183, as are the bulk of
`repository/offline.rs`, `storage/mod.rs` and `commands/download/mod.rs`.
**More importantly: zero bare unwraps exist in any `#[tauri::command]` handler.**
The specific risk this finding was built around — a panic inside a command killing
the task and stranding shared player state — is already absent.
The same correction applies to the lock half: all 33 raw `.lock().unwrap()` hits
were in test modules (three weren't even code, but prose in `utils/lock.rs`'s doc
comment). Production was already fully on `lock_safe()`/`read_safe()`/
`write_safe()`. Converting them was consistency work, not a bug fix.
**What is genuinely worth doing** is a three-site cluster, all the same pattern —
`Runtime::new().unwrap()` in threads owning playback-critical state:
| | Site | Consequence of a panic |
|---|------|------------------------|
| 1 | `session_poller/mod.rs:102` | Poller thread dies silently; it drives remote-mode state *and* offline→online recovery, so the app strands offline with nothing surfaced |
| 2 | `player/mpv_backend.rs:424` | Position reporting stops mid-playback; the scrubber freezes while audio keeps going |
| 3 | `player/android/mod.rs:761` | Same pattern across a JNI boundary; progress reporting dies and no resume points are written |
**Fix:** One shared helper returning `Option<Runtime>` and logging on failure
retires all three. The remaining 16 are startup `expect()`s and two provably
infallible calls.
### D4 · Low · Five files carry a disproportionate share of the complexity
`player/mod.rs` (4,726 lines), `repository/offline.rs` (4,696),
`repository/online.rs` (3,702), `commands/player/mod.rs` (3,299) and
`commands/download/mod.rs` (3,226), plus `VideoPlayer.svelte` (2,778) on the
frontend.
These are the same files the changelog keeps returning to for deadlocks and
playback regressions. Not a defect in itself, and not worth a speculative
refactor — but the next time one of them needs substantial work, splitting it is
likely cheaper than continuing to grow it.
---
## E. Verified sound
Things this audit specifically went looking for and found in good order —
including one that looked alarming from the warning output and turned out to be
fine.
| Area | Finding |
|------|---------|
| **The 9 "MutexGuard across await" warnings are test-only** | All nine sit in `#[tokio::test]` functions holding a serialization lock, not in the production async paths that `CLAUDE.md`'s deadlock gotcha warns about. |
| **The local media server is exemplary** | Loopback-only bind, a 32-hex-char per-session token, lexical `..` folding rather than `canonicalize`, and a test asserting reads stay inside the data directory. |
| **Tauri capabilities are minimal** | Three permissions total — `core:default`, `opener:default`, `core:path:default`. No blanket grants, no `withGlobalTauri`. |
| **SQL is parameterised** | Two `format!`-built statements in the whole Rust tree, neither interpolating caller-controlled input into a query. |
| **R8 keep rules are correct and explained** | JNI-loaded player and security classes, the JavascriptInterface bridges and Media3 are all kept, each with a comment naming the crash it prevents. |
| **Type and boundary gates are green** | `svelte-check`: 0 errors, 0 warnings. `check:boundary` passes with three reviewed allowlist entries. 698 Rust tests and 1,021 frontend tests pass. |
---
## F. Suggested order
Sequenced so the cheap gates land before the work they would have caught. B2
leads because it is the finding a user is most likely to actually feel.
*(B1 originally led this list. It was demoted to row 10 after device testing —
see B1. This is a good advertisement for testing a finding before scheduling
work against it.)*
| # | Finding | What it buys | Effort |
|---|---------|--------------|--------|
| 1 | B2 | Catalogue and credentials stop leaving the device; restore stops failing silently | S — **confirmed on device**: `ALLOW_BACKUP` set, Google transport active |
| 3 | D1 | Lint discipline becomes enforced rather than remembered | S |
| 4 | B3 | The network security config actually holds | S — needs an offline-playback check |
| 5 | A1 | The matrix stops over-reporting on twelve shipped requirements | M — mostly mechanical |
| 6 | D2 | CI stops flaking | S |
| 7 | C1 · C2 | The web layer stops being one interpolation away from full IPC | M — iterate against HLS |
| 8 | A2 · A3 · A4 | The matrix becomes self-consistent and defended by a real gate | M |
| 9 | B4 · B5 · B6 · B7 | Platform hygiene brought level with the SDK target | M |
| 10 | B1 · D3 · C3 · D4 | Long-tail robustness; opportunistic rather than scheduled | L |
+150
View File
@@ -0,0 +1,150 @@
# Defect windows — which bugs were present when
For each fixed defect, the releases it was actually present in. Companion to
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
fault had been shipping before it did.
**"Present since"** is the first *release* containing the defective code, not the
first release where a user could hit it — those differ, sometimes by months, and
the gap is called out where it matters. **"How dated"** records the evidence, so a
row can be re-checked or disputed:
| Method | Meaning |
|--------|---------|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
## Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
for between two weeks and seven weeks short of two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| `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 |
### Why they took so long to surface
Four of these were **latent until a later feature exercised them**, which is why
the fix lands so far from the cause:
- The `videoBitrate` casing was harmless while every download was `original`. It
became visible only once a quality picker existed to select against — and then
produced no error, just a full-size file, because Jellyfin discards an unbound
query key silently.
- The unconditional `Range` header was inert for the same reason: `original` is
the one rung served with a `Content-Length` and real byte-range support. It
started corrupting files in **v0.5.1**, the moment the casing fix made
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
*corruption* to v0.5.1 — a one-release window for the visible symptom.
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
item. Nothing re-opened one until quality switching, transcoded seek and
audio-track switching existed.
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
subtitle track is image-based, since that is what forces the server from
sidecar to burn-in.
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
was hidden by the thumbnail cache falling back to the server copy whenever the
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
silently correct an out-of-range index — which is exactly why it was reported as
"*some* videos have no audio" rather than as a bug in the client.
## Introduced by a feature, fixed later
| Defect | Present since | Fixed in | How dated |
|---|---|---|---|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
written, and no caller passed one until v0.3.0.
- The sync queue and its drain were built, tested and running in v0.4.6 with
neither of its two would-be producers ever called.
- Both halves of the watched-state backend existed with no caller before v0.5.3.
An automated check cannot see any of these — the code is present, tested and
reachable in principle. Only tracing a requirement to a *call site* catches it.
## Short windows (one release or less)
| Defect | Present since | Fixed in | Note |
|---|---|---|---|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
## Fixed twice / never actually broken
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
local original). Both have the same parent `674c8e5c` and the same diff. A merge
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
boundary. The fix shipped in **v0.0.2** and has not regressed.
This is the one case where reading the changelog off `git log` subjects would
have produced a false entry, and it is a good argument for the project's
practice of deriving release notes from TRACES rather than commit subjects.
## Recurring shapes
Four causes account for most of the table:
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
default that was actively wrong, and in three of the four the server's choice
was more expensive than the one intended — burn-in forcing a full re-encode
being the extreme case.
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
dividing by a stale denominator.
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
the position, backend never seeked), end-of-playback dispatch (two paths, one
unreachable), and the surface/attach split in v0.5.0's native video.
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
timer handle the callback had already cleared; the HTML5 toggle keyed
play-vs-pause on `el.paused`, which flips while buffering.
## Reproducing this
The pickaxe rows can be re-derived directly:
```bash
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
git tag --contains <sha> | sort -V | head -1 # first release with it
```
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
across many commits but was **not** used for the rows above: it reliably lands on
whichever commit last touched the adjacent lines, which is usually not the commit
that introduced the defect. It was used only to shortlist candidates.
+141 -21
View File
@@ -16,7 +16,7 @@ For a narrative overview of the system design, see
| UR-003 | Play videos | High | Done | | UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done | | UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done | | UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done | | UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done (Android); **not implemented on Linux** — see IR-005 |
| UR-007 | Navigate media in library | High | Done | | UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done | | UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done | | UR-009 | Connect to Jellyfin to access media | High | Done |
@@ -82,6 +82,10 @@ For a narrative overview of the system design, see
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done | | UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed | | UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed | | UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
--- ---
@@ -97,7 +101,7 @@ External system integrations and platform-specific implementations.
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done | | IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done | | IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) | | IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned | | IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned — genuinely absent: no `mpris`/`souvlaki`/`zbus`/`dbus` code or dependency in the project (`zbus` appears in `Cargo.lock` only transitively, via `tauri-plugin-opener`), and no `navigator.mediaSession` use in the frontend. `player::update_lockscreen_metadata` is a no-op off Android. UR-006 is therefore Android-only |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done | | IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned | | IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done | | IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
@@ -111,8 +115,8 @@ External system integrations and platform-specific implementations.
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done | | IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done | | IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned | | IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned | | IR-018 | Subtitle rendering and selection in the **video** playback backends: ExoPlayer sideloads each track as a `MediaItem.SubtitleConfiguration` and selects by text-track-group position (Android), and the WebKitGTK HTML5 `<video>` element renders `<track kind="subtitles">` children carrying `data-stream-index` (Linux). **Originally scoped to libmpv, which never implemented it**: `MpvBackend` is the audio-only backend here and does not override `PlayerBackend::set_subtitle_track`, so the default `not_implemented()` still stands there. UR-020 is satisfied by the two paths above rather than by MPV | Playback | UR-020 | Done |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned | | IR-019 | Audio track selection in the **video** playback backends: ExoPlayer switches track by index natively (Android), while the HTML5 `<video>` path cannot switch a track in the element and instead re-opens the stream at the chosen `AudioStreamIndex` and resumes at the same position (Linux) — the two outcomes `AudioTrackSwitchResponse` distinguishes. **Originally scoped to libmpv, which never implemented it**: `MpvBackend` does not override `PlayerBackend::set_audio_track`, so the default `not_implemented()` still stands there. UR-021 is satisfied by the two paths above rather than by MPV | Playback | UR-021 | Done |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) | | IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done | | IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done | | IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
@@ -126,6 +130,26 @@ External system integrations and platform-specific implementations.
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) | | IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed | | IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
> integration requirements were written when libmpv was expected to be the single
> playback backend. It is not: `MpvBackend` is the **audio-only** backend, Linux
> plays video through a WebKitGTK HTML5 `<video>` element (HLS/h264), and Android
> plays through ExoPlayer. So:
>
> * **UR-020 / UR-021** (subtitle and audio track selection) are Done, but not by
> MPV — `MpvBackend` overrides neither `PlayerBackend::set_subtitle_track` nor
> `set_audio_track`, leaving the trait's `not_implemented()` default. IR-018 and
> IR-019 have been **re-scoped to the backends that actually deliver them**
> (ExoPlayer sideloaded `SubtitleConfiguration`s and native track switching;
> HTML5 `<track>` children and stream re-open at the chosen `AudioStreamIndex`)
> and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
> * **UR-006** (lockscreen / BLE headset control) is Done **on Android only**, via
> `MediaSessionCompat` (IR-006) and ExoPlayer/`AudioManager` focus (IR-008).
> IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is
> no MPRIS/D-Bus code or dependency in the project, and
> `player::update_lockscreen_metadata` is a no-op off Android. UR-006's status
> was corrected rather than IR-005's.
### 2.2 Jellyfin API Requirements ### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration. API endpoints and data contracts required for Jellyfin integration.
@@ -167,6 +191,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done | | JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done | | JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done | | JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
### 2.3 Development Requirements ### 2.3 Development Requirements
@@ -303,7 +328,7 @@ Internal architecture, components, and application logic.
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done | | DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done | | DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done | | DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done | | DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope was `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to `$APPDATA/thumbnails/**`, since DR-137 moved downloaded media off this protocol and thumbnails are all it still serves | Security | UR-071 | Done |
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done | | DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done | | DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done | | DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
@@ -316,12 +341,56 @@ Internal architecture, components, and application logic.
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done | | DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done | | DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done | | DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type``item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException``400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 3336. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
| DR-194 | Stale pixels in the letterbox bars — the rotation "flash of the previous frame", a ghost control bar stranded in the top bar, each new clock digit drawn over the last (`35:42` with the `1` still showing through the `2`), and menus (sleep timer, quality) leaving their imprint behind. One cause for all of it: **nothing painted the bars.** The window surface is opaque (the theme is not translucent), and for an opaque surface HWUI deliberately does not clear the damaged region before replaying a frame — it assumes the view hierarchy covers every pixel. That hierarchy is window background → video `TextureView` → transparent WebView, and `fitSurfaceToScreen` sizes the TextureView to the *letterboxed* video rect, so the bars were the window background's alone to paint. `setTransparent(true)` cleared that background to `TRANSPARENT`, leaving the bars painted by nobody and whatever was last in the framebuffer surviving in them. Fixed by keeping the window background opaque black while compositing; the WebView's own background is what lets the video through, and the TextureView is drawn on top of the window background, so an opaque one cannot hide it. Three earlier fixes aimed at the window's rotation animation and at TextureView frame-retention (two `postOnAnimation` hops, an `onSurfaceTextureUpdated` reveal, then `ROTATION_ANIMATION_JUMPCUT` + `FLAG_FULLSCREEN`) all missed, because the pixels were never the animation's; the alpha-hiding among them made it worse by blanking the one view that reliably paints its own rect. Those are removed, `FLAG_FULLSCREEN` included — it fought edge-to-edge insets for no gain. Verified on device: ghosting reproduced with native video on, then absent after the fix, across playback, the control bar and a rotation round-trip | Android | UR-003, UR-066 | Done |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done | | DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done | | DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done | | DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done | | DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done | | DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done | | DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
--- ---
@@ -333,29 +402,29 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------| |----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - | | UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 | | UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 | | UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129 | | UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
| UR-005 | - | DR-001, DR-005, DR-009 | | UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - | | UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 | | UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 | | UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - | | UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 | | UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 | | UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - | | UR-012 | IR-009, IR-014 | DR-198 |
| UR-013 | IR-013 | DR-017 | | UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 | | UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 | | UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - | | UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 | | UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 | | UR-018 | IR-013 | DR-015, DR-018, DR-173 |
| UR-019 | IR-015 | DR-022 | | UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 | | UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
| UR-021 | IR-016, IR-019 | DR-024 | | UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
| UR-022 | IR-017 | DR-025 | | UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 | | UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 | | UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028, DR-131, DR-132 | | UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
| UR-026 | - | DR-029, DR-048, DR-050 | | UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 | | UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 | | UR-028 | - | DR-031 |
@@ -370,8 +439,8 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 | | UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 | | UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 | | UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130 | | UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201 |
| UR-041 | IR-026 | DR-053 | | UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 | | UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 | | UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 | | UR-044 | - | DR-056 |
@@ -385,7 +454,7 @@ Internal architecture, components, and application logic.
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 | | UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-053 | IR-029 | DR-074 | | UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 | | UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 | | UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
| UR-056 | - | DR-085 | | UR-056 | - | DR-085 |
| UR-057 | - | DR-086 | | UR-057 | - | DR-086 |
| UR-058 | - | DR-087, DR-142 | | UR-058 | - | DR-087, DR-142 |
@@ -395,12 +464,16 @@ Internal architecture, components, and application logic.
| UR-063 | - | DR-105 | | UR-063 | - | DR-105 |
| UR-064 | - | DR-106 | | UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 | | UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112 | | UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 | | UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 | | UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 | | UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 | | UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138 | | UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 |
--- ---
@@ -549,12 +622,59 @@ Internal architecture, components, and application logic.
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done | | UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done | | UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done | | UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done | | UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done | | UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done | | UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done | | UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done | | UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done | | UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
| UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done |
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
| UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done |
| UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done |
| UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done |
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
| UT-197 | Skipping forward near the end clamps to the duration rather than running past it into an EOF-driven advance | DR-201 | Done |
| UT-198 | An unknown duration still scrubs and still refuses to go negative | DR-201 | Done |
### Integration Tests ### Integration Tests
@@ -567,8 +687,8 @@ Internal architecture, components, and application logic.
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending | | IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending | | IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending | | IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending | | IT-008 | Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 `<track>` children) — *not* via libmpv, which does not implement it | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending | | IT-009 | Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen `AudioStreamIndex`) — *not* via libmpv, which does not implement it | IR-019, UR-021 | Pending |
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending | | IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending | | IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending | | IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
+28 -4
View File
@@ -1,8 +1,12 @@
# Spec: Android native video — transparent-webview spike # Spec: Android native video — transparent-webview spike
**Status:** Spike succeeded — native video confirmed working on a physical **Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag, default off. Flipping that default shipped **audio with no picture** and was
default off. Branch `feat/android-native-video`. reverted (DR-172). Three defects behind that have since been fixed — DR-182
(nothing on the native path could lift the poster overlay), DR-183 (the JS
bridges raced the page load), DR-184 (the SurfaceView was never detached).
Branch `fix/android-native-video-visible`. **The default stays off until the
device criteria below are green.**
**The spike's central question is answered: yes.** A `SurfaceView` *can* be **The spike's central question is answered: yes.** A `SurfaceView` *can* be
composited behind a transparent Tauri WebView on Android. Nothing upstream composited behind a transparent Tauri WebView on Android. Nothing upstream
@@ -227,6 +231,9 @@ The spike is **complete** when one of these is true:
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native. - [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`). - [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`).
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike. - [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path. - [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem. - [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read. - [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
@@ -238,9 +245,26 @@ The spike is **complete** when one of these is true:
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152. - [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way: Either way:
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass. - [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc). - [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
### Why the 2026-08-11 verification and DR-172 do not contradict each other
The spike was reported working on device; the same path then shipped as audio
with no picture. Both are consistent with DR-182: the poster overlay is drawn
only while `isMediaReady` is false, and the native path has no way to set it, so
what the surface shows depends entirely on **whether that overlay is on screen**
— not on whether compositing works. Any run that reached the player through a
path leaving `isMediaReady` already true (a handoff return, a re-render, a
session that had previously played on the HTML5 path) shows video; a cold start
into the native path never does. That is also why DR-172 read the symptom as a
compositing failure: on screen the two are identical, and the one piece of
evidence separating them — `WebView transparent = true` never being logged —
points at DR-183 rather than at the compositing itself.
**This reasoning is not yet device-confirmed.** It explains the reports and is
backed by the code, but the criteria above are what settle it.
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and > Note: this environment has no host WebKitGTK dev packages, no Android SDK and
> no `bun`, so all of the above were run inside the CI builder image > no `bun`, so all of the above were run inside the CI builder image
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind > (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
+125
View File
@@ -0,0 +1,125 @@
# Spec: Library mosaic (library overview + home shortcuts)
**Status:** Implemented
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
## Summary
The library overview and the home "Your Libraries" strip stop being fixed-shape
grids and become a **mosaic**: rows share one height, and each tile is as wide as
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
poster sit in the same row at their own proportions instead of all three being
cropped into whichever box the grid picked. Favourites gain a tile per category,
placed beside the library that category belongs to, alongside the existing
cross-library entry.
## Motivation
Every surface here shows artwork of more than one shape. The grid resolved that
by choosing one shape and cropping to it — and the home strip said so out loud:
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
> height in this mixed row.
Lining them up is right; cropping the covers to do it is not. Holding the
**height** fixed and letting the **width** vary achieves the same alignment with
no crop at all, which is the whole idea of a justified layout.
Favourites had one entry for everything. With per-category tiles, "my favourite
albums" is one tap from the library page rather than a tap plus a tab.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
any per-collection-type table deserves suspicion. The tie-breaker: it does not
decide *what a category means* or what is fetched — it seeds a pixel dimension
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
not a wrong result. The scope mapping, which does decide what is fetched, went to
Rust.
## Design
### Wire
`Library` gains one optional field, derived at construction:
```rust
pub struct Library {
pub id: String,
pub name: String,
pub collection_type: String,
pub image_tag: Option<String>,
pub favorites_scope: Option<SearchScope>, // ← new
}
impl SearchScope {
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
}
```
```ts
type Library = { …; favoritesScope?: SearchScope | null }
```
`Library::new` derives it, so the four construction sites (online views, two
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
not sent as null. No new command, no new event.
### Layout
`src/lib/components/library/mosaic.ts` — pure, no DOM:
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
with pixel boxes. Tiles join a row until the height needed to fill the width
drops to the target; the row closes there and is justified to the container
width, the rounding remainder absorbed by its widest tile. The **last row is
not justified** (one leftover tile would inflate into a banner) — it sits at
the target height, left-aligned.
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
a horizontally scrolling shelf.
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
doesn't turn each library into a billboard.
- Ratios are clamped to a band (0.52.5) so one panorama can't own a row.
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
callback for the second. Measured ratios are committed in one debounced batch
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
image would shuffle the grid under the pointer.
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
**over** the bottom of the artwork. A caption below the box would add height the
layout didn't compute, and a caption that wrapped to two lines would break the
row alignment the mosaic exists to provide.
### Composition
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
favourites entry first, then each library followed by its own category tile. A
category appears **once** — two movie libraries share one favourites list, so a
tile each would be two tiles to the same place. A library whose `favoritesScope`
is absent (Live TV, channels, books) gets no tile rather than one opening an
unfiltered list.
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
home already carries Favourite Movies / Shows / Music rows of its own, and a
second entry point in the strip above them would be redundant.
## Out of scope
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
them nothing but reflow.
- Backdrop/collage artwork for libraries with no image of their own.
- Reordering or pinning libraries.
+146
View File
@@ -0,0 +1,146 @@
# Spec: streaming bitrate cap
**Status:** Implemented
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
## Summary
The viewer picks a bandwidth ceiling for video — from `Original` (no client
limit) down to 720 kbps — and every video the app opens is fetched within it,
live TV included. The choice is made once in Settings and persists across
restarts; a single video can be moved to another ceiling from the player, which
re-opens the stream and resumes where it was without changing the saved default.
## Motivation
Every video URL the app built carried a fixed allowance —
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
negotiation asked for 20 Mbps, and the device profile advertised
`999999999`, which invites the server to direct-play a source of any size. On a
metered or slow connection there was no lever at all short of not watching.
The related UR-070 asks for something adjacent but different: a list of the
renditions *the server can produce for this item*. That needs per-item
`MediaSources` negotiation and is still proposed. What was missing first is
cruder and more valuable: a device-wide budget that holds regardless of what is
playing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
The frontend holds one string — the serde token for the chosen variant — and
labels/details it received from Rust. It never encodes a bitrate, a resolution
or a parameter name.
## Design
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
`video_bitrate()` (= total audio), `max_height()`, `label()`, `detail()`.
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
`repository/online.rs`, read by every builder. Process-wide rather than a field
on `OnlineRepository` because it is a preference about *this device's
connection*: it must survive a repository rebuilt on re-login, and the URL
builders and the negotiation have to agree on it or the cap leaks. This mirrors
`offline::INCLUDE_CATALOG_BROWSE`.
Enforcement points — all four are required:
| Point | What the cap sets |
|-------|-------------------|
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
| `open_live_stream` | `MaxStreamingBitrate` |
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
the server refuse to *direct play* a source fatter than the ceiling; without it
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
it.
IPC:
```rust
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
player_set_stream_quality(repository_handle, quality, use_html5,
current_position, media_source_id, audio_stream_index)
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
```
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
persisted before the field existed load as uncapped).
`player_set_video_settings` applies it and writes it to `app_settings`;
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
tauri-specta only camelCases the `strategy` tag. The facade
(`playerController.setStreamQuality`) dispatches `reloadSource` for
`reloadStream` and does nothing for `native`, because the backend has already
reloaded itself.
Mid-playback the change applies to the current video **and** becomes the process
ceiling for what follows, but it is not persisted: the in-player menu is a "this
film, this connection" control and Settings owns the durable default.
## Out of scope
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
- Adaptive/automatic selection from measured throughput.
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
## Acceptance criteria
- [x] `bun run check` passes.
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
- [x] `bun run test` passes.
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
- [x] New code carries `// TRACES:` comments.
- [x] `bindings.ts` regenerated from Rust.
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
## Testing
Rust (`cargo test`):
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
- `test_video_stream_url_uncapped_keeps_legacy_allowance``Original` is unchanged and adds no `MaxHeight`.
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
The ceiling is process-wide, so tests that depend on it serialise on a guard
(`QualityFixture`) that restores `Original` on drop — including the two
pre-existing stream-URL tests, which would otherwise see another test's cap.
`get_playback_info` and `open_live_stream` need a live server and are not unit
tested; their behaviour is the enum's `max_bitrate()`, which is.
## TRACES
- `StreamingQuality`, `VideoSettings.streaming_quality``UR-074 | DR-162`
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
- Audio-only handoff — `UR-040, UR-074 | DR-162`
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
- Tests — `UT-156`, `UT-157`
## Notes for the implementer
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
- A parallel Claude session may be active in this repo; `git diff` before
repairing unexpected changes. DR-160/161 were claimed by such a session while
this feature was in flight, which is why it is DR-162.
+42 -10
View File
@@ -15,7 +15,7 @@ The CI/CD pipeline automatically validates that code changes are properly traced
Traceability validation lives in `.gitea/workflows/traceability-check.yml`: Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
- ✅ Automatic trace extraction - ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (50%) - ✅ Coverage validation against minimum threshold (82%, ratcheted)
- ✅ Modified file checking - ✅ Modified file checking
- ✅ Artifact preservation - ✅ Artifact preservation
- ✅ Summary reports - ✅ Summary reports
@@ -43,7 +43,7 @@ Extracts all TRACES comments from:
### 2. Coverage Thresholds ### 2. Coverage Thresholds
The workflow checks: The workflow checks:
- **Minimum overall coverage:** 50% - **Minimum overall coverage:** 82% (`MIN_THRESHOLD`)
Denominators are **derived from `docs/requirements.md` at run time** — they are Denominators are **derived from `docs/requirements.md` at run time** — they are
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
@@ -61,8 +61,39 @@ a `TRACES:` comment but is not defined in `requirements.md` is reported as
**orphaned** and does not count toward coverage. UT/IT test identifiers are a **orphaned** and does not count toward coverage. UT/IT test identifiers are a
separate taxonomy and are excluded entirely. separate taxonomy and are excluded entirely.
The workflow **fails** and blocks merge if coverage drops below 50% — or if it The workflow **fails** and blocks merge if coverage drops below the threshold —
computes above 100%, which can only mean the gate is miscounting. or if it computes above 100%, which can only mean the gate is miscounting.
#### Ratchet policy
`MIN_THRESHOLD` **only ever goes up.** It is deliberately set a few points below
the coverage actually achieved (82 against a real 86%), so a genuine regression
trips it. It previously sat at 50 while true coverage was 86%: nearly half the
matrix could have rotted before CI objected.
When coverage rises durably, raise the threshold to just under the new figure.
**Never lower it to make a red build pass** — add the missing TRACES comments
instead. The same number lives in `MIN_COVERAGE_PERCENT` in
`scripts/extract-traces.ts` (so `bun run traces:coverage` gates locally on the
same bar); `scripts/extract-traces.test.ts` fails if the two drift apart.
### 2b. Dangling requirement IDs
```bash
bun run traces:validate
```
Every ID named by a `TRACES:` comment must be defined as a table row in
`docs/requirements.md`. The extractor used to accept any well-formed ID
silently, so a typo or a rename that missed a call site passed unnoticed —
`DR-189` and `UT-188` were referenced from three source files, defined nowhere,
for months.
This check spans **all six** ID types (UR/IR/DR/JA/UT/IT), unlike the coverage
`orphaned` list above, which considers only the four requirement types so that
UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step
**fails the build** on any dangling ID and prints each offender with the files
that reference it.
### 3. Modified File Checking ### 3. Modified File Checking
On pull requests, the workflow: On pull requests, the workflow:
@@ -120,13 +151,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### On Push to Main Branch ### On Push to Main Branch
1. ✅ Extracts all traces from code 1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 50% 2. ✅ Validates coverage is >= 82%
3. ✅ Generates full traceability report 3. ✅ Generates full traceability report
4. ✅ Saves report as artifact 4. ✅ Saves report as artifact
### On Pull Request ### On Pull Request
1. ✅ Extracts all traces 1. ✅ Extracts all traces
2. ✅ Validates coverage >= 50% 2. ✅ Validates coverage >= 82%
3. ✅ Checks modified files for TRACES 3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES 4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format 5. ✅ Suggests proper format
@@ -134,7 +165,8 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### Failure Scenarios ### Failure Scenarios
The workflow **fails** (blocks merge) if: The workflow **fails** (blocks merge) if:
- Coverage drops below 50% - Coverage drops below 82%
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
- JSON extraction fails - JSON extraction fails
- Invalid trace format - Invalid trace format
@@ -174,7 +206,7 @@ made the broken CI arithmetic look plausible for so long.
As of July 2026 overall coverage is ~86% (182/212). As of July 2026 overall coverage is ~86% (182/212).
### Targets ### Targets
- **Short term** (Sprint): Maintain ≥50% overall - **Short term** (Sprint): Maintain ≥82% overall (the current ratchet)
- **Medium term** (Month): Reach 70% overall coverage - **Medium term** (Month): Reach 70% overall coverage
- **Long term** (Release): Reach 90% coverage with focus on: - **Long term** (Release): Reach 90% coverage with focus on:
- IR requirements (API clients) - IR requirements (API clients)
@@ -209,14 +241,14 @@ When submitting a pull request:
- [ ] All new code has TRACES comments linking to requirements - [ ] All new code has TRACES comments linking to requirements
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002` - [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
- [ ] Workflow passes (coverage ≥ 50%) - [ ] Workflow passes (coverage ≥ 82%)
- [ ] No coverage regressions - [ ] No coverage regressions
- [ ] Artifact traceability report was generated - [ ] Artifact traceability report was generated
## Troubleshooting ## Troubleshooting
### "Coverage below minimum threshold" ### "Coverage below minimum threshold"
**Problem:** Workflow fails with coverage < 50% **Problem:** Workflow fails with coverage < 82%
**Solution:** **Solution:**
1. Run `bun run traces:json` locally 1. Run `bun run traces:json` locally
+6853 -4013
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -133,13 +133,14 @@ bun run traces:json | jq '.requirements."UR-005"'
### Before Committing ### Before Committing
1. Ensure all new code has TRACES 1. Ensure all new code has TRACES
2. Format is correct: `// TRACES: ...` 2. Format is correct: `// TRACES: ...`
3. Requirements exist in README.md 3. Requirements exist in `docs/requirements.md``bun run traces:validate`
4. No typos in requirement IDs 4. No typos in requirement IDs (same command catches them)
## CI/CD Validation ## CI/CD Validation
The workflow automatically checks: The workflow automatically checks:
- ✅ Coverage stays >= 50% - ✅ Coverage stays >= 82% (a ratchet — raise it, never lower it)
- ✅ Every traced ID is defined in `docs/requirements.md`
- ✅ New files have TRACES - ✅ New files have TRACES
- ✅ JSON format is valid - ✅ JSON format is valid
- ✅ Reports are generated - ✅ Reports are generated
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jellytau", "name": "jellytau",
"version": "0.4.8", "version": "0.8.0",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
@@ -39,6 +39,7 @@
"traces:json": "bun run scripts/extract-traces.ts --format json", "traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md", "traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage", "traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"traces:validate": "bun run scripts/extract-traces.ts --format validate",
"release:notes": "bun run scripts/release-notes.ts" "release:notes": "bun run scripts/release-notes.ts"
}, },
"license": "MIT", "license": "MIT",
+10 -2
View File
@@ -69,7 +69,8 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
bun run traces # Generate markdown report bun run traces # Generate markdown report
bun run traces:json # Generate JSON report bun run traces:json # Generate JSON report
bun run traces:markdown # Save to docs/traceability.md bun run traces:markdown # Save to docs/traceability.md
bun run traces:coverage # Coverage gate — exits non-zero below 50% bun run traces:coverage # Coverage gate — exits non-zero below the ratchet
bun run traces:validate # Dangling-ID gate — every traced ID must be defined
``` ```
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`) The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
@@ -84,6 +85,12 @@ derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
that appears in a `TRACES:` comment but is not defined in `requirements.md` is that appears in a `TRACES:` comment but is not defined in `requirements.md` is
reported as *orphaned* and does not count toward coverage (see DR-093). reported as *orphaned* and does not count toward coverage (see DR-093).
**`bun run traces:validate` is the dangling-ID gate.** It fails if any traced ID
— including `UT`/`IT`, which coverage deliberately ignores — is not defined as a
table row in `requirements.md`, printing each offender with the files that
reference it. Without it the extractor accepted any well-formed ID silently, so
typos and renames that missed a call site went unreported for months.
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and > **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
> `find-req-implementations.sh` were deleted in July 2026. They read an > `find-req-implementations.sh` were deleted in July 2026. They read an
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/` > undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
@@ -104,7 +111,8 @@ See [docs/traceability.md](../docs/traceability.md) for the latest generated map
The traceability system is integrated with Gitea Actions CI/CD: The traceability system is integrated with Gitea Actions CI/CD:
- Automatically validates TRACES on every push and pull request - Automatically validates TRACES on every push and pull request
- Enforces minimum 50% coverage threshold - Enforces a minimum coverage threshold (a ratchet: raise it, never lower it)
- Fails on dangling IDs — traced but undefined in `requirements.md`
- Warns if new code lacks TRACES comments - Warns if new code lacks TRACES comments
- Generates traceability reports automatically - Generates traceability reports automatically
+6 -4
View File
@@ -11,11 +11,13 @@ echo ""
echo "" echo ""
# Deploy APK — extract build type (default debug), ignoring flags like --clean. # Deploy APK — forward the build type and the side-by-side flag (which decides
BUILD_TYPE="debug" # which package to launch), ignoring build-only flags like --clean and --device.
DEPLOY_ARGS=("debug")
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
debug|release) BUILD_TYPE="$arg" ;; debug|release) DEPLOY_ARGS[0]="$arg" ;;
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
esac esac
done done
./scripts/deploy-android.sh "$BUILD_TYPE" ./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
+24 -1
View File
@@ -23,9 +23,18 @@ echo ""
# which is what a distributable universal APK needs — but for an on-device test # which is what a distributable universal APK needs — but for an on-device test
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build # it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
# only the connected device's architecture; --abi <t> targets one explicitly. # only the connected device's architecture; --abi <t> targets one explicitly.
#
# Side-by-side: the `debug` build type always installs as
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
# real install. `release --debug` puts a *release* build — R8-minified, exactly
# what ships — into that same slot, signed with the local debug keystore. That
# is how you validate minification (R8 stripping JNI-loaded classes has broken
# release APKs here before) without the real signing key and without
# uninstalling the app you actually use.
BUILD_TYPE="debug" BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}" CLEAN="${CLEAN:-0}"
ABI="${ABI:-}" ABI="${ABI:-}"
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
next_is_abi=0 next_is_abi=0
for arg in "$@"; do for arg in "$@"; do
if [ "$next_is_abi" = "1" ]; then if [ "$next_is_abi" = "1" ]; then
@@ -37,10 +46,17 @@ for arg in "$@"; do
--clean) CLEAN=1 ;; --clean) CLEAN=1 ;;
--abi) next_is_abi=1 ;; --abi) next_is_abi=1 ;;
--device) ABI="device" ;; --device) ABI="device" ;;
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;; debug|release) BUILD_TYPE="$arg" ;;
esac esac
done done
# The debug build type is side-by-side unconditionally; the flag only means
# something for a release build.
if [ "$BUILD_TYPE" = "debug" ]; then
SIDE_BY_SIDE=1
fi
# Resolve --device to the attached device's Rust target triple. # Resolve --device to the attached device's Rust target triple.
if [ "$ABI" = "device" ]; then if [ "$ABI" = "device" ]; then
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')" device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
@@ -78,7 +94,14 @@ echo "🎨 Building frontend..."
bun run build bun run build
# Step 2: Build Android APK # Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
# A release build in the debug slot: R8 still runs, but the applicationId is
# suffixed and the debug keystore signs it (read by build.gradle.kts from
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
# .debug install cleanly. Deliberately does NOT write keystore.properties.
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
elif [ "$BUILD_TYPE" = "release" ]; then
# Configure release signing from .env (single source of truth). Must run # Configure release signing from .env (single source of truth). Must run
# after sync-android-sources.sh, since gen/android is (re)generated there. # after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh ./scripts/write-keystore-properties.sh
+40 -5
View File
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
exit 1 exit 1
fi fi
# Build type: debug or release (default: debug) # Build type: debug or release (default: debug). `--debug` alongside `release`
BUILD_TYPE="${1:-debug}" # means the side-by-side release build — same APK path, but it was packaged
# under the .debug applicationId, so the package to launch differs.
BUILD_TYPE="debug"
SIDE_BY_SIDE=0
for arg in "$@"; do
case "$arg" in
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
# separate package, so it installs alongside a real release build — no
# uninstall dance needed.
if [ "$BUILD_TYPE" = "release" ]; then if [ "$BUILD_TYPE" = "release" ]; then
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk" APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
else else
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk" APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
fi fi
if [ "$SIDE_BY_SIDE" = "1" ]; then
APP_PACKAGE="com.dtourolle.jellytau.debug"
else
APP_PACKAGE="com.dtourolle.jellytau"
fi
# Check if APK exists # Check if APK exists
if [ ! -f "$APK_PATH" ]; then if [ ! -f "$APK_PATH" ]; then
echo "❌ APK not found at: $APK_PATH" echo "❌ APK not found at: $APK_PATH"
echo "Run './scripts/build-android.sh $BUILD_TYPE' first" if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
echo "Run './scripts/build-android.sh release --debug' first"
else
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
fi
exit 1 exit 1
fi fi
echo "📦 Installing APK: $APK_PATH" echo "📦 Installing APK: $APK_PATH"
adb install -r "$APK_PATH" echo "📛 Package: $APP_PACKAGE"
if ! adb install -r "$APK_PATH"; then
echo ""
echo "❌ Install failed."
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
echo " '$APP_PACKAGE' signed with a different key is still installed."
echo " Uninstall just that one and retry:"
echo " adb uninstall $APP_PACKAGE"
exit 1
fi
echo "" echo ""
echo "✅ Deployment complete!" echo "✅ Deployment complete!"
echo "🚀 Launch the app on your device" echo "🚀 Launching..."
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|| echo " (auto-launch failed — start it from the launcher)"
+107 -11
View File
@@ -14,7 +14,17 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { countDefinedRequirements, computeCoverage } from "./extract-traces"; import * as fs from "fs";
import * as path from "path";
import {
countDefinedRequirements,
computeCoverage,
findDanglingIds,
MIN_COVERAGE_PERCENT,
} from "./extract-traces";
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const HERE = path.dirname(new URL(import.meta.url).pathname);
describe("countDefinedRequirements", () => { describe("countDefinedRequirements", () => {
it("counts a well-formed table row as a defined requirement", () => { it("counts a well-formed table row as a defined requirement", () => {
@@ -82,6 +92,80 @@ Some prose explaining that UR-005 relates to DR-001 and JA-002.
expect(defined.ids.has("DR-050")).toBe(true); expect(defined.ids.has("DR-050")).toBe(true);
expect(defined.ids.has("UR-999")).toBe(false); expect(defined.ids.has("UR-999")).toBe(false);
}); });
it("collects UT/IT rows separately, out of the coverage denominator", () => {
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
// comment may name them) without ever moving the coverage ratio.
const md = `
| UR-001 | A | High | Done |
| UT-001 | Player state transitions | DR-001 | Pending |
| IT-004 | Playback end-to-end | DR-002 | Pending |
`;
const defined = countDefinedRequirements(md);
expect(defined.total).toBe(1);
expect(defined.ids.has("UT-001")).toBe(false);
expect(defined.testIds.has("UT-001")).toBe(true);
expect(defined.testIds.has("IT-004")).toBe(true);
});
});
describe("findDanglingIds", () => {
const defined = {
UR: 1,
IR: 0,
DR: 1,
JA: 0,
total: 2,
ids: new Set(["UR-001", "DR-001"]),
testIds: new Set(["UT-001"]),
};
it("flags a requirement ID that requirements.md does not define", () => {
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
});
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
// UT-188 sat in three source files, defined nowhere, entirely unreported.
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
});
it("accepts every ID that is defined, requirement or test", () => {
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
});
it("deduplicates and sorts, so one typo is reported once", () => {
expect(
findDanglingIds(["DR-189", "DR-189", "UR-999", "DR-189"], defined)
).toEqual(["DR-189", "UR-999"]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
// e.g. an unrelated "AB-123" caught by the loose ID regex.
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
});
});
describe("coverage threshold", () => {
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
// Two files must agree on the gate: the script (local `traces:coverage`)
// and the workflow. Drift means the local gate and CI disagree about what
// passes, which is how the 50%-while-actually-86% slack went unnoticed.
const workflow = fs.readFileSync(
path.resolve(HERE, "../.gitea/workflows/traceability-check.yml"),
"utf-8"
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
});
it("is a ratchet: never lower it to make a red build pass", () => {
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
});
}); });
describe("computeCoverage", () => { describe("computeCoverage", () => {
@@ -92,6 +176,7 @@ describe("computeCoverage", () => {
JA: 0, JA: 0,
total: 4, total: 4,
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]), ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
testIds: new Set<string>(),
}; };
it("computes coverage as traced ∩ defined over defined", () => { it("computes coverage as traced ∩ defined over defined", () => {
@@ -138,7 +223,15 @@ describe("computeCoverage", () => {
}); });
it("reports 0% rather than NaN when nothing is defined", () => { it("reports 0% rather than NaN when nothing is defined", () => {
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() }; const empty = {
UR: 0,
IR: 0,
DR: 0,
JA: 0,
total: 0,
ids: new Set<string>(),
testIds: new Set<string>(),
};
const cov = computeCoverage([], empty); const cov = computeCoverage([], empty);
expect(cov.percent).toBe(0); expect(cov.percent).toBe(0);
expect(Number.isNaN(cov.percent)).toBe(false); expect(Number.isNaN(cov.percent)).toBe(false);
@@ -163,20 +256,23 @@ describe("live requirements.md", () => {
// (total 114) while the real file had grown to 211. Update these numbers // (total 114) while the real file had grown to 211. Update these numbers
// deliberately when requirements are added — that edit is the signal the // deliberately when requirements are added — that edit is the signal the
// denominator is live rather than frozen. // denominator is live rather than frozen.
const fs = require("fs");
const path = require("path");
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const here = path.dirname(new URL(import.meta.url).pathname);
const md = fs.readFileSync( const md = fs.readFileSync(
path.resolve(here, "../docs/requirements.md"), path.resolve(HERE, "../docs/requirements.md"),
"utf-8" "utf-8"
); );
const defined = countDefinedRequirements(md); const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(71); expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32); expect(defined.IR).toBe(32);
expect(defined.DR).toBe(148); // 192 = 187 + four requirements added independently on four audit branches,
expect(defined.JA).toBe(35); // plus DR-201 (lockscreen skip resolution). Originally 191 = 187 + four
expect(defined.total).toBe(286); // that landed together: DR-189 (control-bar auto-hide), DR-198 (asset
// scope/CSP), DR-199 (webview mixed-content) and DR-200 (the
// POST_NOTIFICATIONS media-session exemption; renumbered from 198 on
// merge, where it collided). Each branch bumped for its own — merged,
// they sum. Resolve this by summing, never by taking one side.
expect(defined.DR).toBe(192);
expect(defined.JA).toBe(36);
expect(defined.total).toBe(335);
}); });
}); });
+103 -3
View File
@@ -37,8 +37,27 @@ interface TracesData {
/** Requirements *defined* in requirements.md — the coverage denominators. */ /** Requirements *defined* in requirements.md — the coverage denominators. */
defined?: { UR: number; IR: number; DR: number; JA: number; total: number }; defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
coverage?: CoverageResult; coverage?: CoverageResult;
/** Traced IDs of any type that requirements.md does not define. */
dangling?: string[];
} }
/**
* Minimum overall requirement coverage the traceability gate accepts.
*
* **Ratchet policy: this number only ever goes up.** It is set a few points
* below the coverage actually achieved, so a real regression trips it instead of
* being absorbed by slack. It sat at 50 while true coverage was 86%, which meant
* half the matrix could rot before CI noticed. When coverage rises durably,
* raise this to sit just under the new figure. Do **not** lower it to make a
* failing build pass add the missing TRACES comments instead.
*
* `.gitea/workflows/traceability-check.yml` carries the same number as
* `MIN_THRESHOLD`; `scripts/extract-traces.test.ts` fails if the two drift.
*
* TRACES: | DR-093
*/
export const MIN_COVERAGE_PERCENT = 82;
// Repo root, derived from this script's location (scripts/ -> repo root). // Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files. // Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
// //
@@ -222,7 +241,10 @@ export interface DefinedRequirements {
DR: number; DR: number;
JA: number; JA: number;
total: number; total: number;
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
ids: Set<string>; ids: Set<string>;
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
testIds: Set<string>;
} }
export interface CoverageResult { export interface CoverageResult {
@@ -247,11 +269,18 @@ export interface CoverageResult {
*/ */
export function countDefinedRequirements(markdown: string): DefinedRequirements { export function countDefinedRequirements(markdown: string): DefinedRequirements {
const ids = new Set<string>(); const ids = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/; const testIds = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA|UT|IT)-(\d{3})\s*\|/;
for (const line of markdown.split("\n")) { for (const line of markdown.split("\n")) {
const match = line.match(ROW_ID); const match = line.match(ROW_ID);
if (match) ids.add(`${match[1]}-${match[2]}`); if (!match) continue;
const id = `${match[1]}-${match[2]}`;
// UT/IT rows live in §4 and are collected separately: they must not enter
// the coverage denominator, but they still need to exist for a `TRACES:`
// comment to be allowed to name them (see findDanglingIds).
if (match[1] === "UT" || match[1] === "IT") testIds.add(id);
else ids.add(id);
} }
const countOf = (type: string) => const countOf = (type: string) =>
@@ -264,9 +293,39 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
JA: countOf("JA"), JA: countOf("JA"),
total: ids.size, total: ids.size,
ids, ids,
testIds,
}; };
} }
/**
* Every traced ID that requirements.md defines nowhere a typo, a rename that
* missed a call site, or a reference to a deleted requirement.
*
* This is broader than `CoverageResult.orphaned`, which only ever considers the
* four requirement types because a UT/IT entry among the orphans would corrupt
* the coverage ratio's reporting. Dangling detection has no such constraint, so
* it checks all six ID types against both defined sets. Before it existed, the
* extractor accepted any well-formed ID silently: `DR-189` and `UT-188` were
* referenced from `controlsVisibility.ts` and `VideoPlayer.svelte` for months
* without being defined anywhere, and nothing reported it.
*
* TRACES: | DR-093
*/
export function findDanglingIds(
tracedIds: string[],
defined: DefinedRequirements
): string[] {
const KNOWN_TYPE = /^(UR|IR|DR|JA|UT|IT)-\d{3}$/;
const dangling = new Set(
tracedIds
.filter((id) => KNOWN_TYPE.test(id))
.filter((id) => !defined.ids.has(id) && !defined.testIds.has(id))
);
return [...dangling].sort();
}
/** /**
* Coverage is the *intersection* of traced and defined IDs over defined IDs. * Coverage is the *intersection* of traced and defined IDs over defined IDs.
* *
@@ -408,6 +467,13 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
console.log(" Fix the TRACES comment or add the requirement."); console.log(" Fix the TRACES comment or add the requirement.");
} }
if (data.dangling && data.dangling.length > 0) {
console.log("");
console.log(
`⚠️ Dangling IDs (incl. UT/IT): ${data.dangling.join(", ")} — run \`bun run traces:validate\`.`
);
}
// A ratio above 100% means the computation is broken (the condition that hid // A ratio above 100% means the computation is broken (the condition that hid
// the stale-denominator bug for so long). Fail loudly rather than report it. // the stale-denominator bug for so long). Fail loudly rather than report it.
if (cov.percent > 100) { if (cov.percent > 100) {
@@ -427,6 +493,37 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
return 0; return 0;
} }
/**
* Hard gate on dangling IDs: a `TRACES:` comment may only name an ID that
* requirements.md actually defines. Prints every offender with the files that
* reference it, so the fix is mechanical.
*
* TRACES: | DR-093
*/
function reportDangling(data: TracesData): number {
const dangling = data.dangling ?? [];
if (dangling.length === 0) {
console.log("✅ All traced IDs are defined in docs/requirements.md");
return 0;
}
console.log("❌ TRACES reference IDs that docs/requirements.md does not define:");
console.log("");
for (const id of dangling) {
const files = [
...new Set((data.requirements[id] ?? []).map((e) => e.file)),
].sort();
console.log(` ${id}`);
for (const file of files) console.log(` ${file}`);
}
console.log("");
console.log("Fix each one by either:");
console.log(" • correcting the ID in the TRACES comment (typo/rename), or");
console.log(" • adding the requirement as a table row in docs/requirements.md.");
return 1;
}
// Main — guarded so this module stays importable from extract-traces.test.ts. // Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) { if (import.meta.main) {
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -447,11 +544,14 @@ if (import.meta.main) {
total: defined.total, total: defined.total,
}; };
data.coverage = computeCoverage(allTraced, defined); data.coverage = computeCoverage(allTraced, defined);
data.dangling = findDanglingIds(allTraced, defined);
if (format === "json") { if (format === "json") {
console.log(generateJson(data)); console.log(generateJson(data));
} else if (format === "coverage") { } else if (format === "coverage") {
process.exit(reportCoverage(data, 50)); process.exit(reportCoverage(data, MIN_COVERAGE_PERCENT));
} else if (format === "validate") {
process.exit(reportDangling(data));
} else { } else {
console.log(generateMarkdown(data)); console.log(generateMarkdown(data));
} }
+25 -4
View File
@@ -1,13 +1,34 @@
#!/bin/bash #!/bin/bash
# View Android logcat output filtered for the app # View Android logcat output filtered for the app.
#
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
#
# The debug build has applicationIdSuffix ".debug" so it can be installed
# alongside a release build; pick the package to follow accordingly.
set -e set -e
APP_PACKAGE="com.jellytau.app" BUILD_TYPE="${1:-debug}"
if [ "$BUILD_TYPE" = "release" ]; then
APP_PACKAGE="com.dtourolle.jellytau"
else
APP_PACKAGE="com.dtourolle.jellytau.debug"
fi
echo "📱 Showing logcat for $APP_PACKAGE" echo "📱 Showing logcat for $APP_PACKAGE"
echo "Press Ctrl+C to stop" echo "Press Ctrl+C to stop"
echo "" echo ""
# Filter logcat for the app's package name # Prefer PID-scoped output when the app is running — it drops the noise that a
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust" # text grep can't. Fall back to the old keyword filter when it isn't (so you can
# start the script first and then launch the app).
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
if [ -n "$PID" ]; then
echo " (attached to pid $PID)"
adb logcat --pid="$PID"
else
echo " (app not running — falling back to keyword filter)"
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
fi
+11 -3
View File
@@ -81,8 +81,16 @@ fi
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a # builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
# *downgrade* and Android refuses the update. # *downgrade* and Android refuses the update.
# #
# code = 1000 + major*10000 + minor*100 + patch # The floor has to clear the highest code actually in the field, which is not the
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000. # same as the highest this formula has produced. v0.5.2 shipped versionCode
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
# every 0.5.x release built from it was an un-installable downgrade for anyone
# already on v0.5.2, which is exactly the failure this block exists to prevent.
# The multipliers are widened and the floor raised past 5002 accordingly.
#
# code = 10000 + major*1000000 + minor*1000 + patch
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
PROPS="src-tauri/gen/android/app/tauri.properties" PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to # Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
@@ -93,7 +101,7 @@ if [ -f "$PROPS" ]; then
MIN=$(echo "$CORE" | cut -d. -f2) MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3) PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}" : "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT )) CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
echo " versionCode=$CODE (from $CORE)" echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS" sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
+24 -12
View File
@@ -106,21 +106,33 @@ describe("set-version.sh", () => {
}); });
describe("Android versionCode", () => { describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never // A newer release must never produce a smaller number than an older one, or
// produce a smaller number than an older one. // Android refuses the update. The floor tracks the highest code actually in
it("clears the 1000 floor shipped by earlier builds", () => { // the field, which is NOT the same as the highest this formula has produced:
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
// the `minor*100` formula that replaced it yields only 1502 for that same
// version — so every 0.5.x release built from it was an un-installable
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
it("clears the highest code shipped by earlier builds", () => {
run("0.0.1"); run("0.0.1");
expect(versionCode()).toBeGreaterThan(1000); // v0.5.2 shipped 5002; anything at or below that cannot install over it.
expect(versionCode()).toBeGreaterThan(5002);
}); });
it("uses 1000 + major*10000 + minor*100 + patch", () => { it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
run("0.5.3");
expect(versionCode()).toBeGreaterThan(5002);
});
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
const cases: Array<[string, number]> = [ const cases: Array<[string, number]> = [
["0.0.14", 1014], ["0.0.14", 10014],
["0.0.15", 1015], ["0.0.15", 10015],
["0.1.0", 1100], ["0.1.0", 11000],
["0.4.8", 1408], ["0.4.8", 14008],
["0.5.0", 1500], ["0.5.0", 15000],
["1.0.0", 11000], ["0.5.3", 15003],
["1.0.0", 1010000],
]; ];
for (const [version, code] of cases) { for (const [version, code] of cases) {
seed(tmp); seed(tmp);
@@ -145,7 +157,7 @@ describe("set-version.sh", () => {
// stripped before the arithmetic. // stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => { it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1"); run("0.6.0-rc1");
expect(versionCode()).toBe(1600); expect(versionCode()).toBe(16000);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1"); expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
}); });
}); });
+22
View File
@@ -118,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
"$RES_DST"/drawable*/ic_launcher_background.xml "$RES_DST"/drawable*/ic_launcher_background.xml
fi fi
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
# slow, and a hard failure when the CDN drops the connection mid-transfer
# ("Unexpected end of file from server"), which is what broke the release APK
# job. The builder image ships the matching distribution under /opt/gradle/dist,
# so when it's present repoint the wrapper at that local zip and build offline.
# Outside the image (dev machines) the properties file is left untouched and the
# wrapper downloads as usual.
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$WRAPPER_PROPS" ]; then
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
"$WRAPPER_PROPS"
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
elif [ -n "$WANTED_VERSION" ]; then
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
fi
fi
echo "✓ Android sources synced successfully" echo "✓ Android sources synced successfully"
+92
View File
@@ -0,0 +1,92 @@
/**
* Guards the shipped webview security configuration.
*
* `csp` was `null` and the asset protocol was scoped to the whole storage root,
* which is the directory holding the SQLite database and the encrypted-token
* fallback file. Both are one-character regressions away and neither is visible
* in any behavioural test, so they are asserted here instead: the restrictive
* half of the policy must stay restrictive, and the permissive half must keep
* the schemes playback actually needs.
*
* TRACES: UR-012, UR-071 | DR-198 | UT-193
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const config = JSON.parse(
readFileSync(resolve(__dirname, "../src-tauri/tauri.conf.json"), "utf-8")
);
const security = config.app.security;
/** Split a CSP string into `directive -> sources`. */
function directives(csp: string): Record<string, string[]> {
const map: Record<string, string[]> = {};
for (const part of csp.split(";")) {
const [name, ...sources] = part.trim().split(/\s+/);
if (name) map[name] = sources;
}
return map;
}
describe("tauri.conf.json CSP", () => {
it("is set at all — a null CSP hands any injected script the full IPC surface", () => {
expect(typeof security.csp).toBe("string");
expect(security.csp.length).toBeGreaterThan(0);
});
const csp = directives(security.csp as string);
it("locks down script execution", () => {
// Tauri injects a nonce for SvelteKit's inline bootstrap script at build
// time, so 'self' alone is enough and inline/eval must never be re-added.
expect(csp["script-src"]).toEqual(["'self'"]);
expect(csp["object-src"]).toEqual(["'none'"]);
expect(csp["frame-src"]).toEqual(["'none'"]);
expect(csp["base-uri"]).toEqual(["'self'"]);
expect(csp["default-src"]).toEqual(["'self'"]);
});
it("keeps the schemes playback and thumbnails depend on", () => {
// The asset protocol under both names convertFileSrc emits.
expect(csp["img-src"]).toContain("asset:");
expect(csp["img-src"]).toContain("http://asset.localhost");
expect(csp["media-src"]).toContain("asset:");
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
expect(csp["media-src"]).toContain("blob:");
expect(csp["worker-src"]).toContain("blob:");
// The token-guarded loopback media server (DR-137).
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
// Tauri's invoke transport.
expect(csp["connect-src"]).toContain("ipc:");
expect(csp["connect-src"]).toContain("http://ipc.localhost");
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
for (const directive of ["img-src", "media-src", "connect-src"]) {
expect(csp[directive]).toContain("http:");
expect(csp[directive]).toContain("https:");
}
});
it("never widens a data directive into script execution", () => {
for (const [name, sources] of Object.entries(csp)) {
if (name === "script-src" || name === "worker-src") {
expect(sources).not.toContain("'unsafe-eval'");
expect(sources).not.toContain("'unsafe-inline'");
}
// A bare `*` would re-admit every scheme, including file:.
expect(sources).not.toContain("*");
}
});
});
describe("tauri.conf.json asset protocol scope", () => {
const scope: string[] = security.assetProtocol.scope;
it("covers only the thumbnail cache, not the storage root", () => {
expect(scope).toEqual(["$APPDATA/thumbnails/**"]);
// The database and the encrypted-token fallback live directly in $APPDATA.
expect(scope).not.toContain("$APPDATA/**");
});
});
+1 -1
View File
@@ -2018,7 +2018,7 @@ dependencies = [
[[package]] [[package]]
name = "jellytau" name = "jellytau"
version = "0.4.8" version = "0.8.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"async-trait", "async-trait",
+10 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jellytau" name = "jellytau"
version = "0.4.8" version = "0.8.0"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
@@ -23,11 +23,15 @@ debug = "line-tables-only"
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
# protocol-asset serves downloaded media and cached thumbnails to the webview # protocol-asset serves cached thumbnails to the webview (asset://localhost on
# over http://asset.localhost; without it convertFileSrc yields a URL nothing # Linux/macOS, http://asset.localhost on Windows/Android); without it
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which # convertFileSrc yields a URL nothing answers. Paired with
# scopes it to $APPDATA/**. # app.security.assetProtocol in tauri.conf.json, which scopes it to
# TRACES: UR-071 | DR-134 # $APPDATA/thumbnails/** — the one directory still read through this protocol.
# Downloaded media went the same way until DR-137 moved it to the loopback media
# server, so the database, the encrypted-token fallback file and downloads/ are
# all outside the grant now.
# TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
tauri = { version = "2", features = ["protocol-asset"] } tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
+51
View File
@@ -41,6 +41,57 @@ When you need to modify Android/Kotlin files:
- If you only edit `src-tauri/android/`, your changes won't be in the build - If you only edit `src-tauri/android/`, your changes won't be in the build
- **You must edit both** (or edit source and copy to generated) - **You must edit both** (or edit source and copy to generated)
### Debug and release install side by side
The **debug** build type sets `applicationIdSuffix = ".debug"` in
`app/build.gradle.kts`, so a debug build is a genuinely separate Android app:
| build | applicationId | launcher name | versionName | signed with |
|---|---|---|---|---|
| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) |
| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore |
| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore |
`release --debug` is the **side-by-side release**: fully R8-minified, exactly
what ships, but packaged into the debug slot and signed with the local debug
keystore. It exists because R8 has broken release APKs here before (stripping
JNI-loaded player/security classes), and reproducing that previously meant
building with the real key and clobbering your working install. It shares the
applicationId *and* signature with the plain debug build, so the two replace
each other cleanly; only the versionName suffix tells you which is installed.
```bash
./scripts/build-and-deploy.sh release --device --debug # build + install it
```
The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`.
CI never sets it, so distributable release builds are untouched.
That means:
- **No uninstall step.** Debug builds are signed with the local auto-generated
`~/.android/debug.keystore`, release builds with the real key. Two different
keys on the *same* package is `INSTALL_FAILED_UPDATE_INCOMPATIBLE`; two
different packages is just two apps.
- Each has **its own data directory** — separate settings, credentials,
downloads and offline cache. A debug experiment cannot corrupt the state of
the build you actually use. This is not optional and cannot be shared:
Android gives each applicationId its own UID and enforces the boundary in the
kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an
already-installed app anyway.) You log in again in the debug app, once.
- Only the *application* id changes. Kotlin classes stay in the `namespace`
package `com.dtourolle.jellytau`, so the JNI class lookups in
`src-tauri/src/player/android/mod.rs`, the manifest `<service>` entry and the
R8 keep rules in `proguard-jellytau.pro` are all unaffected. The FileProvider
authority is `${applicationId}.fileprovider`, so it follows the suffix
automatically.
- The launcher labels come from the `appLabel` / `activityLabel`
manifestPlaceholders (`AndroidManifest.xml` uses `${appLabel}`), *not* from
`resValue`, which would collide with Tauri's generated `strings.xml`.
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
(defaults to debug).
### Key Files ### Key Files
Player-related Kotlin files: Player-related Kotlin files:
+51 -2
View File
@@ -22,11 +22,25 @@ val keystoreProperties = Properties().apply {
} }
} }
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
// debug applicationId slot, signed with the local debug keystore — so you can
// test what minification actually produces (R8 stripping JNI-loaded classes has
// broken release APKs here before) without the real signing key and without
// uninstalling your working install. Unset in CI, so distributable release
// builds are untouched.
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
android { android {
compileSdk = 36 compileSdk = 36
namespace = "com.dtourolle.jellytau" namespace = "com.dtourolle.jellytau"
defaultConfig { defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false" manifestPlaceholders["usesCleartextTraffic"] = "false"
// Launcher/app names come from placeholders so the debug build can
// rename itself without touching the generated strings.xml (a
// resValue() override there would collide with Tauri's own entries).
manifestPlaceholders["appLabel"] = "@string/app_name"
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
applicationId = "com.dtourolle.jellytau" applicationId = "com.dtourolle.jellytau"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
@@ -45,6 +59,21 @@ android {
} }
buildTypes { buildTypes {
getByName("debug") { getByName("debug") {
// Distinct applicationId so the debug build installs SIDE BY SIDE
// with a release/store install instead of demanding an uninstall
// (different signing keys on the same package = INSTALL_FAILED_
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
// and its own offline cache — the two are fully independent apps.
//
// This changes only the *application* id. The Kotlin/JNI classes
// stay in the `namespace` package (com.dtourolle.jellytau), so the
// fully-qualified class names Rust looks up over JNI, the manifest
// <service> entry and the R8 keep rules are all unaffected. The
// FileProvider authority is already ${applicationId}-relative.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
manifestPlaceholders["usesCleartextTraffic"] = "true" manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true isDebuggable = true
isJniDebuggable = true isJniDebuggable = true
@@ -56,7 +85,18 @@ android {
} }
} }
getByName("release") { getByName("release") {
if (keystoreProperties.getProperty("storeFile") != null) { if (sideBySideRelease) {
// Same slot, name and version scheme as the debug build type,
// plus "-release" so you can tell from Settings > Apps which of
// the two is currently sitting there. Signed with the debug
// keystore: it shares a signature with the debug build, so the
// two replace each other cleanly instead of colliding.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug-release"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
signingConfig = signingConfigs.getByName("debug")
} else if (keystoreProperties.getProperty("storeFile") != null) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
isMinifyEnabled = true isMinifyEnabled = true
@@ -67,8 +107,17 @@ android {
) )
} }
} }
// Java 17 bytecode. AGP 8.11 already requires a JDK 17 toolchain to run
// (the builder image ships openjdk-17), so "1.8" was only capping the
// bytecode we emit, not the JDK in use. Kotlin's jvmTarget and javac's
// source/targetCompatibility must agree or AGP 8 fails the build, so all
// three move together.
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { kotlinOptions {
jvmTarget = "1.8" jvmTarget = "17"
} }
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
+75 -7
View File
@@ -11,6 +11,12 @@
(An earlier version of this file was a partial <application> fragment on the (An earlier version of this file was a partial <application> fragment on the
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
declared never reached any built APK. It is folded in properly below.) declared never reached any built APK. It is folded in properly below.)
${appLabel} / ${activityLabel} are manifestPlaceholders set in
app/build.gradle.kts: they resolve to @string/app_name and
@string/main_activity_title for release, and to "JellyTau Debug" for the
debug build type (which also carries applicationIdSuffix ".debug" so it
installs alongside a release build).
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -19,22 +25,85 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!--
Declared, and deliberately NEVER requested at runtime. That is not an
oversight, and an audit has flagged it once already — please read before
"fixing" it in either direction.
Nothing the app posts today needs it. The only notification it produces is
the playback service's, which is a MediaStyle notification carrying a valid
MediaSession token, and "Notifications related to media sessions are exempt
from this behavior change". Verified on device (HONOR ROD2-W09, Android 16
/ SDK 36): appops `POST_NOTIFICATION: ignore`, granted=false, and the
transport notification simultaneously live with all three actions and
working lockscreen controls. So there is no permission dialog, because a
prompt the app does not need is a prompt that can be permanently denied for
nothing. Media3 does not require the declaration either — media3-session's
own manifest declares no permissions, and the MediaSessionService guide
asks only for the two FOREGROUND_SERVICE permissions above.
It stays declared because the exemption is narrow: it is a property of the
NOTIFICATION (MediaStyle *and* a non-null session token), not of the
foreground service, and it covers media and self-managed-call notifications
only. A download-completion notice (UR-011) would be an ordinary
notification and would be silently dropped. Adding one means requesting
this permission at runtime — AndroidX ActivityResultContracts.
RequestPermission from MainActivity, at the point the feature is used — and
handling refusal; keeping the declaration is what makes that a one-file
change. See JellyTauPlaybackService.warnIfNotificationWillBeDropped.
TRACES: UR-006 | DR-198
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support --> <!--
<uses-feature android:name="android.software.leanback" android:required="false" /> Android TV is deliberately NOT declared here.
A LEANBACK_LAUNCHER category and an android.software.leanback uses-feature
used to sit in this manifest, but nothing behind them: no D-pad focus
model, no TV-sized layouts, and neither of the two declarations Play's TV
validation also requires (android.hardware.touchscreen required="false"
and an android:banner). That combination is the worst of both - it offers
the app to TV launchers while failing TV review and shipping a UI that
cannot be driven without a touchscreen.
Re-declare all four together (leanback feature, LEANBACK_LAUNCHER,
touchscreen required="false", banner) once a focus pass has actually been
done, not before.
-->
<!--
android:allowBackup / android:dataExtractionRules below:
no cloud backup, no device-to-device transfer (UR-012).
Credentials are encrypted under an Android Keystore key, and Keystore keys
are NEVER backed up. A restored install would therefore get the
jellytau_secure_prefs ciphertext with no key to open it - the app would
look signed in and silently fail every request, which is worse than a
login screen. Everything else in the data dir (the SQLite catalogue:
library metadata, watch history, download bookkeeping) is a rebuildable
mirror of the Jellyfin server, so backing it up buys nothing and exports
the user's library and viewing history to their Google account.
allowBackup covers API 24-30 completely, and kills *cloud* backup on API
31+. It does NOT stop device-to-device transfer there, so
@xml/data_extraction_rules (API 31+) excludes both channels explicitly. No
android:fullBackupContent is needed: over the API 23-30 range where it
would govern, allowBackup="false" has already turned backup off entirely.
-->
<application <application
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="${appLabel}"
android:theme="@style/Theme.jellytau" android:theme="@style/Theme.jellytau"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
android:launchMode="singleTask" android:launchMode="singleTask"
android:label="@string/main_activity_title" android:label="${activityLabel}"
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
@@ -42,8 +111,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support --> <!-- No LEANBACK_LAUNCHER: see the Android TV note above. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -0,0 +1,67 @@
package com.dtourolle.jellytau
import android.app.Activity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* Hides and restores the Android system bars for full-screen video.
*
* TRACES: UR-066 | DR-157
*
* ## Why the web layer cannot do this
*
* `document.documentElement.requestFullscreen()` is the only fullscreen control
* the frontend has, and inside an Android WebView it does nothing to the
* *Activity*: it expands the fullscreen element within the web viewport and
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` which
* MainActivity must call, and which SDK 36 makes non-optional the WebView
* already spans the whole window, so "fullscreen" was a no-op that changed
* nothing on screen while the status bar and navigation/gesture bar stayed
* painted over the video.
*
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
* which is reachable only from native code. Hence this bridge.
*
* ## Behaviour
*
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
* a swipe from either edge brings them back *transiently* over the video,
* auto-hiding again rather than permanently resizing the window mid-playback.
* That is the standard behaviour for immersive video and keeps the system's own
* back/home gestures reachable.
*
* [exit] restores them. It must be called when leaving fullscreen **and** when
* the player is torn down, or the bars stay hidden on the library screens behind
* it.
*
* Both must run on the main thread; the callers in MainActivity post them there,
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
*
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
* republishes them and the player's control layer stops reserving space it no
* longer needs.
*/
object ImmersiveModeBridge {
private fun controller(activity: Activity): WindowInsetsControllerCompat =
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
fun enter(activity: Activity) {
controller(activity).apply {
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
android.util.Log.d("ImmersiveMode", "system bars hidden")
}
/** Restore the system bars. Safe to call when they are already showing. */
fun exit(activity: Activity) {
controller(activity).show(WindowInsetsCompat.Type.systemBars())
android.util.Log.d("ImmersiveMode", "system bars restored")
}
}
@@ -10,6 +10,7 @@ import android.webkit.WebView
import android.view.View import android.view.View
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0 private var configAttempts = 0
@@ -52,6 +53,42 @@ class MainActivity : TauriActivity() {
*/ */
private var bridgesInstalledOn: WebView? = null private var bridgesInstalledOn: WebView? = null
/**
* wry hands us the WebView here, and this is the only point at which the
* bridges can be installed *deterministically*.
*
* WebView binds an injected object into JS at **page-load time**: an
* addJavascriptInterface call that lands after the page has loaded does not
* appear to that page at all. The bridges used to be installed from
* [configureWebViewForMedia], which finds the WebView by walking the view
* tree 500 ms after onCreate a race against Tauri's own page load, and one
* that is *permanent* when lost, because the identity guard then declines to
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
* silently: every one of them is called through an optional chain, so a
* missing bridge is a no-op rather than an error. That is a candidate
* explanation for DR-172's central piece of evidence native video shipped
* with `WebView transparent = false` logged and `= true` never appearing,
* i.e. the enable call never reaching Kotlin.
*
* `WryActivity.setWebView()` calls this immediately before wry issues the
* first `loadUrl`, so a bridge installed here is bound by the time any page
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
* of our own onCreate so only work that needs nothing but the WebView
* belongs here. Insets are deliberately left to
* [configureWebViewForMedia], which runs later and on every resume.
*
* TRACES: UR-003, UR-004 | DR-183
*/
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -165,7 +202,9 @@ class MainActivity : TauriActivity() {
private fun configureWebViewForMedia() { private fun configureWebViewForMedia() {
try { try {
val webView = findWebView(window.decorView) // onWebViewCreate normally got here first; the tree walk is the fallback
// for a WebView we were never handed.
val webView = mediaWebView ?: findWebView(window.decorView)
if (webView == null) { if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)") android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
@@ -183,33 +222,47 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...") android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView mediaWebView = webView
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
//
// configureWebViewForMedia() runs from onCreate's delayed post AND from
// every onResume (plus each WebView re-find), so this used to re-inject
// all four bridges repeatedly - 5 times in a 45s session. WebView binds
// injected objects at page-load time; re-injecting over a live page
// leaves JS holding a stale proxy. The object stays truthy while its
// methods vanish, which surfaced as a flood of
// "WebView: Unknown object" chromium errors and, in JS,
// "TypeError: setEnabled is not a function".
//
// The visible bug: the background-audio toggle turned blue but never
// reached native, so backgroundAudioEnabled stayed false, onStop never
// dispatched 'jellytau-background', and a locked screen killed audio
// instantly (UR-040). Audio focus and PiP broke the same way.
//
// The settings/WebChromeClient work below is idempotent and must keep
// running on resume; only the bridge injection is one-shot.
// Re-push the safe-area insets. Unlike addJavascriptInterface this is // Re-push the safe-area insets. Unlike addJavascriptInterface this is
// idempotent and MUST re-run: a page load discards the inline style the // idempotent and MUST re-run: a page load discards the inline style the
// last push set, so the WebView would otherwise be left with no insets. // last push set, so the WebView would otherwise be left with no insets.
WindowInsetsBridge.attachWebView(webView) WindowInsetsBridge.attachWebView(webView)
// Normally already done by onWebViewCreate; this is the fallback path.
installJavascriptBridges(webView)
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
*
* This runs from [onWebViewCreate] the only point early enough to be bound
* before the first page load and from [configureWebViewForMedia] as a
* fallback. The latter runs from onCreate's delayed post AND from every
* onResume (plus each WebView re-find), so without the identity guard this
* re-injected every bridge repeatedly 5 times in a 45s session. WebView
* binds injected objects at page-load time; re-injecting over a live page
* leaves JS holding a stale proxy. The object stays truthy while its methods
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
* errors and, in JS, "TypeError: setEnabled is not a function".
*
* The visible bug: the background-audio toggle turned blue but never reached
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
* Audio focus and PiP broke the same way.
*
* Settings/WebChromeClient work is idempotent and must keep running on
* resume, so it lives in [configureWebViewSettings], not here.
*
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
*/
private fun installJavascriptBridges(webView: WebView) {
try {
if (webView === bridgesInstalledOn) { if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection") android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return return
} }
bridgesInstalledOn = webView bridgesInstalledOn = webView
@@ -246,6 +299,19 @@ class MainActivity : TauriActivity() {
fun setAutoEnterEnabled(enabled: Boolean) { fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled autoEnterPipEnabled = enabled
} }
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
}
}, "AndroidPictureInPicture") }, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -304,16 +370,45 @@ class MainActivity : TauriActivity() {
@JavascriptInterface @JavascriptInterface
fun setTransparent(transparent: Boolean) { fun setTransparent(transparent: Boolean) {
handler.post { handler.post {
val color = if (transparent) { mediaWebView?.setBackgroundColor(
android.graphics.Color.TRANSPARENT if (transparent) {
} else { android.graphics.Color.TRANSPARENT
android.graphics.Color.BLACK } else {
} android.graphics.Color.BLACK
mediaWebView?.setBackgroundColor(color) }
// The WebView's window/surface must also stop painting opaque, or a )
// hardware-accelerated WebView still composites its own background. // The WINDOW background stays OPAQUE — including while compositing.
// It is the only thing that paints the pixels the video does not
// cover, and clearing it was the whole defect.
//
// This window's surface is opaque: the theme is not translucent and
// `dumpsys window` shows no translucency flag on it. For an opaque
// surface HWUI deliberately does NOT clear the damaged region before
// replaying a frame — it assumes the view hierarchy paints every
// pixel it owns. That hierarchy is: window background, then the video
// TextureView, then this transparent WebView. `fitSurfaceToScreen`
// sizes the TextureView to the *letterboxed* video rect, so the bars
// around the video are painted by the window background and nothing
// else.
//
// Setting that background TRANSPARENT therefore left the bars painted
// by nobody, and stale framebuffer content simply survived in them:
// a whole ghost copy of the control bar stranded in the top bar, and
// each new clock digit composited over the one before it ("35:42"
// with the 1 still showing through the 2). The rotation flash is the
// same bug at full-screen scale — the pre-rotation image persisting
// in what became the new bars — which is why neither
// ROTATION_ANIMATION_JUMPCUT nor revealing on frame arrival ever
// touched it. Both were aimed at the window animation; the pixels
// were never the animation's.
//
// The WebView's own background, set above, is what lets the video
// through. An opaque window background cannot hide it: the
// TextureView is drawn on top of it, not under it.
//
// TRACES: UR-003, UR-066 | DR-194
window.setBackgroundDrawable( window.setBackgroundDrawable(
android.graphics.drawable.ColorDrawable(color) android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)
) )
android.util.Log.d("MainActivity", "WebView transparent = $transparent") android.util.Log.d("MainActivity", "WebView transparent = $transparent")
} }
@@ -325,6 +420,28 @@ class MainActivity : TauriActivity() {
}, "AndroidVideoSurface") }, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added") android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
// inside a WebView cannot touch the Activity window, so without this the
// status and navigation bars stayed painted over full-screen video.
webView.addJavascriptInterface(object : Any() {
/** Hide the system bars for full-screen playback. */
@JavascriptInterface
fun enter() {
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
}
/** Restore the system bars on leaving fullscreen or the player. */
@JavascriptInterface
fun exit() {
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
}
/** Whether native immersive mode exists (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidImmersive")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' added")
// Window insets (safe areas). The push path above races the page load, so // Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge. // the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets") webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
@@ -336,10 +453,8 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed") dispatchWebEvent("jellytau-network-changed")
} }
configureWebViewSettings(webView)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e) android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
} }
} }
@@ -387,9 +502,52 @@ class MainActivity : TauriActivity() {
javaScriptEnabled = true javaScriptEnabled = true
domStorageEnabled = true domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true // The three settings below used to read
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW // allowFileAccess = true
// allowContentAccess = true
// mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW
// which handed the webview a blanket cleartext opt-in and undid
// res/xml/network_security_config.xml, whose whole point is that only
// 127.0.0.1 is exempt from the cleartext ban and that this "must not
// become a blanket cleartext opt-in" (DR-138). Nothing needed any of it:
//
// - `file://` is never loaded. Cached thumbnails go through
// `convertFileSrc` (imageCache.ts), which on Android resolves to
// `http://asset.localhost/...` — a Tauri custom protocol answered by
// wry's request interceptor, not the filesystem. Downloaded media goes
// through `media_local_url` → the loopback HTTP server on 127.0.0.1
// (media_server.rs, DR-137), which exists precisely *because* the
// asset/file route cannot stream a large file.
// - `content://` is never loaded either. The manifest's FileProvider is
// for outbound share intents, not for webview navigation.
// - Mixed content never arises. Tauri serves the UI from
// `http://tauri.localhost` (`use_https_scheme` is false by default and
// is not set in tauri.conf.json), and both the loopback media server
// and `asset.localhost` are loopback/`.localhost` origins, which
// Chromium treats as potentially trustworthy — so they are not mixed
// content in the first place. A plain-HTTP *remote* Jellyfin server
// would be, but the network security config already rejects it before
// the mixed-content check is ever reached, so ALWAYS_ALLOW bought
// nothing and only widened the hole.
//
// COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge, not
// the default: the platform default at targetSdk 21+ is NEVER_ALLOW, so
// this is still one step looser than "stop overriding". It keeps passive
// content (images) working if some path the analysis above missed turns
// out to need it, which matters because this change cannot be verified
// anywhere but a device. Tighten to NEVER_ALLOW once offline video and
// cached artwork are confirmed on real hardware.
//
// `allowFileAccess = false` is the targetSdk-30+ platform default being
// restored; `allowContentAccess = false` is a genuine tightening (its
// default is true) and is the one to look at first if anything that used
// to render stops.
//
// TRACES: UR-071 | DR-199
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
android.util.Log.d("MainActivity", "WebView fully configured for media playback") android.util.Log.d("MainActivity", "WebView fully configured for media playback")
} }
@@ -46,6 +46,62 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "native video path check failed", e)
false
}
/** /**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API, * Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app. * and the user (or device manufacturer) can disable the feature per-app.
@@ -64,15 +120,10 @@ object PictureInPictureManager {
*/ */
fun canEnterPip(activity: Activity): Boolean { fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false if (!isPipSupported(activity)) return false
return try { // Either surface will do: the native one, or the WebView's `<video>`,
val player = JellyTauPlayer.getInstance() // which is what actually plays while experimentalNativeVideo is off.
player.isPlayingVideo() && // (DR-160)
player.getSurfaceView() != null && return isNativeVideoPath() || html5VideoActive
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
} }
/** /**
@@ -125,32 +176,47 @@ object PictureInPictureManager {
val player = try { val player = try {
JellyTauPlayer.getInstance() JellyTauPlayer.getInstance()
} catch (e: Exception) { } catch (e: Exception) {
return null null
} }
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio // The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape. // by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width val surface = player?.getSurfaceView()
val height = surface.height if (surface != null && surface.width > 0 && surface.height > 0) {
if (width <= 0 || height <= 0) return null return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
val ratio = width.toDouble() / height.toDouble() // No native surface: the WebView is the video, so use the intrinsic size
val minRatio = 1.0 / 2.39 // the frontend reported. (DR-160)
val maxRatio = 2.39 return html5AspectRatio
val clamped = ratio.coerceIn(minRatio, maxRatio) }
// Scale to integers; Rational(width, height) directly can overflow for /**
// large surfaces, and the clamped value may not match the raw pixels. * Clamp a ratio to the range Android accepts and express it as a [Rational].
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content. Scaled to integers because
* `Rational(width, height)` can overflow for large surfaces, and the clamped
* value may not match the raw pixels anyway.
*/
private fun clampedRatio(ratio: Double): Rational {
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
return Rational((clamped * 1000).toInt(), 1000) return Rational((clamped * 1000).toInt(), 1000)
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction { private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try { // On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
JellyTauPlayer.getInstance().getExoPlayer().isPlaying // and the button would be stuck showing "Play" mid-playback. (DR-160)
} catch (e: Exception) { val isPlaying = if (isNativeVideoPath()) {
false try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
} }
val (iconRes, title, controlType, requestCode) = if (isPlaying) { val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -222,11 +288,20 @@ object PictureInPictureManager {
*/ */
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) { fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) { if (isInPipMode) {
hideWebView(activity) // Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
registerReceiver(activity) registerReceiver(activity)
dispatchWebEvent(activity, "jellytau-pip-entered")
} else { } else {
unregisterReceiver(activity) unregisterReceiver(activity)
showWebView() showWebView()
dispatchWebEvent(activity, "jellytau-pip-exited")
// The surface was laid out against the tiny PiP bounds; re-fit it to // The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized. // the restored full-screen bounds or the video stays postage-stamp sized.
try { try {
@@ -237,6 +312,23 @@ object PictureInPictureManager {
} }
} }
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
}
}
private fun hideWebView(activity: Activity) { private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView) val webView = findWebView(activity.window.decorView)
if (webView == null) { if (webView == null) {
@@ -264,14 +356,30 @@ object PictureInPictureManager {
val r = object : BroadcastReceiver() { val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try { val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
JellyTauPlayer.getInstance()
} catch (e: Exception) { if (isNativeVideoPath()) {
return val player = try {
} JellyTauPlayer.getInstance()
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) { } catch (e: Exception) {
CONTROL_PLAY -> player.play() return
CONTROL_PAUSE -> player.pause() }
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
} }
// Swap the button to reflect the new state. // Swap the button to reflect the new state.
updatePipActions(activity) updatePipActions(activity)
@@ -1,7 +1,7 @@
package com.dtourolle.jellytau package com.dtourolle.jellytau
import android.app.Activity import android.app.Activity
import android.view.SurfaceView import android.view.TextureView
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import com.dtourolle.jellytau.player.JellyTauPlayer import com.dtourolle.jellytau.player.JellyTauPlayer
@@ -14,15 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
*/ */
object VideoOverlayManager { object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null private var attachedSurfaceView: TextureView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null private var listenerContentView: ViewGroup? = null
/** /**
* Attach the video SurfaceView to the Activity's content view. * Attach the video view to the Activity's content view.
* *
* The SurfaceView is added at index 0 (bottom of z-order) so it renders * Added at index 0 (bottom of the z-order) so it renders behind the Tauri
* behind the Tauri WebView, allowing Svelte controls to overlay on top. * WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
* is a TextureView, so "behind" is ordinary view z-order within one window
* rather than a separate surface punched through it which is what makes
* the overlay above it repaint reliably.
* *
* @param activity The Activity to attach the surface to * @param activity The Activity to attach the surface to
*/ */
@@ -77,16 +80,29 @@ object VideoOverlayManager {
} }
/** /**
* Detach the video SurfaceView from the Activity's view hierarchy. * Detach the video SurfaceView from the view hierarchy.
* *
* @param activity The Activity to detach the surface from * Must be called on the main thread.
*
* This had **no callers at all**, which made [attachVideoSurface] one-way:
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
* without removing the view, so every native video left its SurfaceView
* parented to the content view for the life of the process and the next one
* added another beneath it. The stack was invisible while the WebView was
* opaque, and [isVideoSurfaceAttached] which gates
* `PictureInPictureManager.canEnterPip` stayed true forever afterwards.
*
* Removes from the view's *own* parent rather than looking the content view
* up from an Activity, so it cannot leave a view behind when the Activity
* has been recreated under it.
*
* TRACES: UR-003, UR-041 | DR-184
*/ */
fun detachVideoSurface(activity: Activity) { fun detachVideoSurface() {
try { try {
removeLayoutListener() removeLayoutListener()
attachedSurfaceView?.let { surfaceView -> attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content) (surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
contentView.removeView(surfaceView)
attachedSurfaceView = null attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy") android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
} }
@@ -27,6 +27,17 @@ import com.google.common.util.concurrent.ListenableFuture
* *
* Media commands are routed back to Rust via JNI to ensure proper * Media commands are routed back to Rust via JNI to ensure proper
* queue management for next/previous track operations. * queue management for next/previous track operations.
*
* This class owns both sessions: the media3 [MediaSession] the service contract
* requires, and the legacy [MediaSessionCompat] that actually carries the
* lockscreen transport. The compat session is flagged
* FLAG_HANDLES_MEDIA_BUTTONS or FLAG_HANDLES_TRANSPORT_CONTROLS, which is what
* makes a Bluetooth headset's AVRCP play/pause/skip arrive as a transport
* callback; every one of those callbacks is forwarded to Rust through
* nativeOnMediaCommand rather than acted on locally, so the player stays the
* single source of truth and the session remains a consumer of its state.
*
* TRACES: UR-006 | IR-006
*/ */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class JellyTauPlaybackService : MediaSessionService() { class JellyTauPlaybackService : MediaSessionService() {
@@ -119,6 +130,54 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("seek:$positionSeconds") nativeOnMediaCommand("seek:$positionSeconds")
} }
// media3 seeks by more routes than seekTo(long), and the ones below
// reach the *real* ExoPlayer if they are not overridden — bypassing
// Rust entirely and operating on the handoff stream's relative
// timeline. That is the same mechanism as the truncation bug, reached
// by a different door.
//
// seekToDefaultPosition is deliberately swallowed rather than
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
// player and then calls play(); on a handoff stream the seek lands at
// stream zero — the point the screen was locked at — which is exactly
// the reported jump-back. Sending "seek:0.0" instead would be worse
// still, restarting the whole episode. Rust already owns what "play
// after the stream ended" means (truncation recovery, or advancing to
// the next episode), and the play() that follows reaches it, so the
// right move here is to not move at all.
//
// TRACES: UR-040, UR-005 | DR-159
override fun seekToDefaultPosition() {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
)
}
override fun seekToDefaultPosition(mediaItemIndex: Int) {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
)
}
// `currentPosition` is ExoPlayer's own, so it is relative during a
// handoff; the base makes the target absolute, which is what Rust
// expects from every command on this boundary.
override fun seekBack() {
val target =
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun seekForward() {
val target =
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun stop() { override fun stop() {
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
} }
@@ -180,6 +239,21 @@ class JellyTauPlaybackService : MediaSessionService() {
nativeOnMediaCommand("previous") nativeOnMediaCommand("previous")
} }
// Fast-forward/rewind map onto the same two commands on purpose.
// Rust decides whether a skip advances the queue or scrubs
// +30s/-10s, based on whether a background-audio handoff owns
// playback (DR-201); routing these separately would put that
// decision in two places and let them disagree.
override fun onFastForward() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Fast-forward pressed")
nativeOnMediaCommand("next")
}
override fun onRewind() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Rewind pressed")
nativeOnMediaCommand("previous")
}
override fun onStop() { override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
@@ -197,9 +271,103 @@ class JellyTauPlaybackService : MediaSessionService() {
} }
} }
/**
* Whether this process could post an *ordinary* notification and have the
* user see it.
*
* Deliberately **not** a gate on anything this service posts today see
* [warnIfNotificationWillBeDropped]. `POST_NOTIFICATIONS` is declared in the
* manifest but never requested, so on Android 13+ this is normally `false`,
* and that is the intended state. It is read only to decide whether a
* token-less notification would be dropped.
*/
private fun hasPostNotificationsPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
/**
* The media-session token is what makes this service's notifications legal
* without `POST_NOTIFICATIONS` do not drop it.
*
* Android 13 (API 33) gates notifications behind the `POST_NOTIFICATIONS`
* runtime permission, and a foreground-service notification is explicitly
* **not** exempt: "Android 13 (API level 33) and higher supports a runtime
* permission for sending non-exempt (including Foreground Services (FGS))
* notifications from an app: POST_NOTIFICATIONS", and with it denied the
* user "still see[s] notices related to foreground services in the Task
* Manager but [doesn't] see them in the notification drawer".
*
* A *media-session* notification is exempt, however: "Notifications related
* to media sessions are exempt from this behavior change." That exemption is
* a property of the notification, not of the service the platform decides
* it from the posted `Notification` itself, which must carry `MediaStyle`
* **and** a valid `MediaSession` token. Every notification this service
* builds does (`MediaStyle().setMediaSession(mediaSessionCompat.sessionToken)`,
* with `mediaSessionCompat` created in `onCreate`, i.e. before any post), so
* the shade entry and the lockscreen transport controls behind UR-006 appear
* whether or not the permission was ever granted. That is why this app asks
* for nothing at runtime and shows the user no permission dialog.
*
* The trap it leaves is a silent one, and it is worse than a missing shade
* entry which is what this exists to make loud. The platform predicate is
* `Notification.isMediaNotification()`, requiring MediaStyle **and** a
* non-null `EXTRA_MEDIA_SESSION`; `NotificationManagerService` uses it to
* decide whether to drop the post, and SystemUI's media carousel
* (`MediaDataProcessor.onNotificationAdded`) is gated on *the same*
* predicate. So a token-less notification is blocked before it reaches the
* notification listener, and the lockscreen/Quick Settings transport
* controls the whole of UR-006 never appear at all, with no error and no
* log anywhere. `mediaSessionCompat?.sessionToken` is a null-safe call, so
* that failure is one stray initialisation-order change away.
*
* The exemption also covers only media and self-managed-call notifications,
* so a genuinely non-media notification a download-completion notice
* (UR-011), say gets none of it. Adding one means requesting
* `POST_NOTIFICATIONS` at runtime first (AndroidX
* `ActivityResultContracts.RequestPermission`, launched from `MainActivity`
* at the point the feature is used, handling refusal), not merely calling
* `notify`; the manifest keeps the declaration so that stays a one-file
* change. Verified unchanged across API 3336.
*
* TRACES: UR-006 | DR-200
*/
private fun warnIfNotificationWillBeDropped(token: MediaSessionCompat.Token?) {
if (token != null) return
if (hasPostNotificationsPermission()) return
android.util.Log.e(
"JellyTauPlaybackService",
"Posting a notification with NO MediaSession token while POST_NOTIFICATIONS " +
"is denied: it is not exempt and Android will drop it silently. " +
"Lockscreen/shade transport controls (UR-006) will be missing."
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Start as foreground service immediately to avoid crash // Start as foreground service immediately to avoid crash
// Media3 will replace this with its own notification // Media3 will replace this with its own notification
//
// startForeground() is deliberately NOT gated on POST_NOTIFICATIONS, and
// an audit asking for such a guard has been answered once already — do
// not re-raise it. Two independent reasons:
//
// 1. The notification does not need the permission. It is exempt because
// it is a media-session notification (see
// warnIfNotificationWillBeDropped). Device evidence, HONOR ROD2-W09 on
// Android 16 / SDK 36: appops reports `POST_NOTIFICATION: ignore` and
// `granted=false`, while the same dumpsys shows this service
// isForeground=true with `foregroundNoti=Notification(category=
// transport actions=3 vis=PUBLIC)` live and the lockscreen transport
// controls working.
// 2. Skipping this call after startForegroundService() is a hard contract
// violation — the system kills the process with "did not then call
// Service.startForeground()". So a guard here would convert a cosmetic
// problem into a crash.
//
// A denied permission must degrade to a missing *notification*, never to
// a missing startForeground.
//
// TRACES: UR-006 | DR-200
val notification = createBasicNotification() val notification = createBasicNotification()
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
@@ -215,6 +383,11 @@ class JellyTauPlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
// onCreate builds mediaSessionCompat, and onStartCommand cannot run
// before onCreate, so this is expected to be non-null here.
val sessionToken = mediaSessionCompat?.sessionToken
warnIfNotificationWillBeDropped(sessionToken)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("JellyTau") .setContentTitle("JellyTau")
.setContentText("Playing") .setContentText("Playing")
@@ -222,7 +395,7 @@ class JellyTauPlaybackService : MediaSessionService() {
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setStyle( .setStyle(
androidx.media.app.NotificationCompat.MediaStyle() androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken) .setMediaSession(sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view .setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
) )
.addAction( .addAction(
@@ -262,23 +435,32 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = "" private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen // The handoff base (ms): during a background-audio handoff the audio stream is
// MediaSession. During a background-audio handoff the audio stream is // requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports // starts at 0 *there* and every position it reports is relative to it. This
// position RELATIVE to that point (starting at 0). The metadata duration, // is the number that converts one back to a real position on the episode.
// however, is the full absolute length — so without this base the scrubber //
// thumb sits near 0:00 on a full-length bar. Set from the known handoff // It is deliberately read, not applied, here. This used to be a display-only
// position via setPositionOffset(); 0 for normal playback. // correction added at the two setPlaybackState calls below, which left every
private var positionOffsetMs: Long = 0L // other consumer — progress reporting to Jellyfin, the frontend, media3's own
// seeks — working in the relative timeline while treating it as absolute, each
// crossing silently losing exactly `base` seconds. The conversion now happens
// once, in JellyTauPlayer's position tick, so everything downstream of it
// speaks the episode's timeline; applying it again here would double-count.
//
// TRACES: UR-040 | DR-159
@Volatile
var handoffBaseMs: Long = 0L
private set
/** /**
* Set the base position offset (seconds) applied to lockscreen positions. * Set the handoff base (seconds). Called by the native layer when entering or
* Called by the native layer when entering/exiting a background-audio handoff. * leaving a background-audio handoff; 0 clears it for normal playback, where
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute). * ExoPlayer's position is already absolute.
*/ */
fun setPositionOffset(offsetSeconds: Double) { fun setHandoffBase(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L) handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms") android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
} }
/** /**
@@ -314,8 +496,9 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build()) session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state (position made absolute via the base offset). // Already absolute: this call comes from Rust, whose stored position is on
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs)) // the episode's timeline. (DR-159)
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes // While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive // arrive on the session poller thread and can race with (or arrive
@@ -337,15 +520,15 @@ class JellyTauPlaybackService : MediaSessionService() {
* notification. Without this, the lockscreen scrubber freezes at the position * notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback. * from the last play/pause and drifts out of sync with actual playback.
* *
* @param position Position in milliseconds * @param position Absolute position in milliseconds, on the item's own
* timeline the caller has already applied [handoffBaseMs].
* @param isPlaying Whether playback is currently active * @param isPlaying Whether playback is currently active
*/ */
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) { fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying lastIsPlaying = isPlaying
// Absolute position for the scrubber = relative ExoPlayer position + base offset. session.setPlaybackState(buildPlaybackState(isPlaying, position))
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Only rebuild the notification when the play/pause icon actually flips. // Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) { if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying) updateNotification(lastTitle, lastArtist, isPlaying)
@@ -375,6 +558,14 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
// Advertised so the system draws seek affordances alongside the
// skip arrows: during a background-audio handoff the backend
// resolves skip to a +30s/-10s scrub rather than a queue advance
// (DR-201), and a control that scrubs should not look like one
// that changes track. Rust owns which of the two a press means;
// these only describe what the session can do.
PlaybackStateCompat.ACTION_FAST_FORWARD or
PlaybackStateCompat.ACTION_REWIND or
PlaybackStateCompat.ACTION_SEEK_TO PlaybackStateCompat.ACTION_SEEK_TO
) )
.setState( .setState(
@@ -388,6 +579,24 @@ class JellyTauPlaybackService : MediaSessionService() {
/** /**
* Update the notification with current media metadata and playback state. * Update the notification with current media metadata and playback state.
* This should be called whenever metadata or playback state changes. * This should be called whenever metadata or playback state changes.
*
* This `notify()` reuses [NOTIFICATION_ID], so while the service is
* foreground it updates the foreground notification in place. It is **not**
* guarded on the service being foreground, and does not need to be, because
* the exemption that keeps it postable is a property of the notification
* (MediaStyle + session token) rather than of the foreground state see
* [warnIfNotificationWillBeDropped].
*
* That distinction is load-bearing, because this *is* reachable with the
* service alive but not foreground. Every caller arrives over JNI from Rust
* on a non-main thread against [getInstance], which is non-null from
* `onCreate` to `onDestroy`: it can therefore interleave between `onCreate`
* and `onStartCommand`, and a media3 `MediaSessionService` is also created
* by a plain *bind* from a MediaController with no `startForeground` at all.
* Were the exemption a foreground-service one, those windows would silently
* drop the update; being a media-session one, they do not.
*
* TRACES: UR-006 | DR-200
*/ */
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) { private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
val intent = packageManager.getLaunchIntentForPackage(packageName) val intent = packageManager.getLaunchIntentForPackage(packageName)
@@ -398,6 +607,12 @@ class JellyTauPlaybackService : MediaSessionService() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
// The token is what exempts this from POST_NOTIFICATIONS; losing it here
// would make every metadata update vanish from the shade and lockscreen
// while the service kept running. See warnIfNotificationWillBeDropped.
val sessionToken = mediaSessionCompat?.sessionToken
warnIfNotificationWillBeDropped(sessionToken)
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setContentText(artist) .setContentText(artist)
@@ -405,7 +620,7 @@ class JellyTauPlaybackService : MediaSessionService() {
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setStyle( .setStyle(
androidx.media.app.NotificationCompat.MediaStyle() androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken) .setMediaSession(sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view .setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
) )
.addAction( .addAction(
@@ -8,8 +8,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.SurfaceHolder import android.view.TextureView
import android.view.SurfaceView
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -225,9 +224,12 @@ class JellyTauPlayer(private val appContext: Context) {
/** Media type enum */ /** Media type enum */
enum class MediaType { AUDIO, VIDEO } enum class MediaType { AUDIO, VIDEO }
/** SurfaceView for video playback */ /** TextureView for video playback — see getOrCreateSurfaceView() for why. */
private var surfaceView: SurfaceView? = null private var videoView: TextureView? = null
private var surfaceHolder: SurfaceHolder? = null
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */ /** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0 private var videoWidth: Int = 0
private var videoHeight: Int = 0 private var videoHeight: Int = 0
@@ -261,7 +263,15 @@ class JellyTauPlayer(private val appContext: Context) {
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build() .build()
// Create ExoPlayer with audio focus handling // Create ExoPlayer with audio focus handling.
//
// For audio playback ExoPlayer manages focus itself: handleAudioFocus=true
// makes it request AUDIOFOCUS_GAIN on play, duck on a transient loss, and
// pause on a call or another app taking focus. Video re-applies this per
// load with handleAudioFocus=false and drives focus manually instead (see
// requestAudioFocus), because a video needs delayed-focus handling.
//
// TRACES: UR-004, UR-006 | IR-008
exoPlayer = ExoPlayer.Builder(appContext) exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or // Pause when the audio output is removed (wired headphones unplugged or
@@ -1030,16 +1040,41 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { if (exoPlayer.isPlaying) {
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) // THE boundary between the two timelines, and the only place
// the conversion happens.
//
// During a background-audio handoff the stream is requested
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
// is that point and everything it reports is relative to it.
// The base used to be added only where a position was *shown*
// (the lockscreen scrubber), leaving progress reports to
// Jellyfin, the frontend and the truncation maths all working
// in the relative timeline while treating it as absolute —
// each crossing losing exactly `base` seconds, which is why the
// jump-back distance varied with where the screen was locked.
// Shifting once, here, means every consumer downstream speaks
// the episode's timeline and none of them needs to know a
// handoff happened.
//
// The duration is shifted with it, so position and duration
// stay on the same timeline — the stream's own length is only
// what remains after the handoff point.
//
// TRACES: UR-040 | DR-159
val service = JellyTauPlaybackService.getInstance()
val baseMs = service?.handoffBaseMs ?: 0L
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
val position = positionMs / 1000.0 val position = positionMs / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0 val duration =
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration") android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration) nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the // Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the // MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync. // scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true) service?.updatePlaybackPosition(positionMs, true)
} }
delay(POSITION_UPDATE_INTERVAL_MS) delay(POSITION_UPDATE_INTERVAL_MS)
} }
@@ -1053,52 +1088,111 @@ class JellyTauPlayer(private val appContext: Context) {
} }
/** /**
* Get or create the SurfaceView for video playback. * Get or create the video view, and hand it to ExoPlayer.
* Returns the view ID that can be attached to the view hierarchy.
* *
* Note: The surface is created but not automatically attached to the view hierarchy. * This is a **TextureView**, not a SurfaceView, and that is the whole point.
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it. *
* A SurfaceView renders on its own layer *outside* the app window and punches
* a transparent hole through the window to show it. Anything drawn above
* that hole for us, the entire Svelte UI in a transparent WebView is at
* the mercy of that composition path, and Android's own graphics
* documentation says plainly that "overlays do not currently work correctly
* with SurfaceView or TextureView". On device that showed up as the WebView
* overlay silently dropping its incremental damage: the clock text stopped
* advancing on screen while the DOM kept updating (slider 476 479 across
* three seconds behind a display showing neither), the control bar would not
* fade, and rotation lost the transport UI. Only *structural* DOM changes
* got through, which is why the play overlay an `{#if}` block that is added
* and removed always appeared to work while the progress bar never did.
*
* A TextureView is an ordinary view: its frames are drawn as a texture inside
* the window's normal rendering pass, so there is no second layer, no
* transparent region, and the WebView above composites like it would over any
* other view. This is the standard remedy for ExoPlayer overlay problems and
* is why media3 offers `surface_type="texture_view"` at all.
*
* The cost is real and accepted: TextureView uses more power and memory than
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
* is unaffected only presentation changes so the reason native video
* exists survives the trade.
*
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
* there is deliberately no listener of ours here; adding one would displace
* it and the video would never appear.
*
* Note: the view is created but not attached to the hierarchy. Call
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
*
* TRACES: UR-003, UR-004 | DR-192
*/ */
fun getOrCreateSurfaceView(): Int { fun getOrCreateSurfaceView(): Int {
if (surfaceView == null) { if (videoView == null) {
surfaceView = SurfaceView(appContext).apply { videoView = TextureView(appContext).apply {
layoutParams = FrameLayout.LayoutParams( layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
// Render BEHIND WebView - video shows through transparent areas // The view is opaque where video is drawn; the WebView above it
setZOrderMediaOverlay(false) // is what supplies transparency, exactly as before.
isOpaque = true
// Set up SurfaceHolder callbacks // Own the listener rather than calling `setVideoTextureView`,
holder.addCallback(object : SurfaceHolder.Callback { // which installs ExoPlayer's own. Handing ExoPlayer the Surface
override fun surfaceCreated(holder: SurfaceHolder) { // directly is the same wiring `setVideoTextureView` does
android.util.Log.d("JellyTauPlayer", "Surface created") // internally, and owning the listener keeps surface creation and
surfaceHolder = holder // teardown symmetrical with `videoSurface` below.
exoPlayer.setVideoSurfaceHolder(holder) //
// (This was originally introduced to observe frame arrival for
// the letterbox artefact. That turned out to be the wrong lead —
// see fitSurfaceToScreen — but the explicit wiring is worth
// keeping on its own terms.)
//
// TRACES: UR-003, UR-004 | DR-194
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
videoSurface?.release()
videoSurface = android.view.Surface(texture)
exoPlayer.setVideoSurface(videoSurface)
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer") android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
} }
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { override fun onSurfaceTextureSizeChanged(
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}") texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun onSurfaceTextureDestroyed(
android.util.Log.d("JellyTauPlayer", "Surface destroyed") texture: android.graphics.SurfaceTexture
exoPlayer.clearVideoSurfaceHolder(holder) ): Boolean {
surfaceHolder = null exoPlayer.setVideoSurface(null)
videoSurface?.release()
videoSurface = null
return true
} }
})
override fun onSurfaceTextureUpdated(
texture: android.graphics.SurfaceTexture
) {
}
}
} }
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
} }
return surfaceView!!.hashCode() return videoView!!.hashCode()
} }
/** /**
* Get the SurfaceView instance (for VideoOverlayManager). * Get the video view instance (for VideoOverlayManager).
* Returns null if no surface has been created yet. * Returns null if none has been created yet.
*/ */
fun getSurfaceView(): SurfaceView? { fun getSurfaceView(): TextureView? {
return surfaceView return videoView
} }
/** /**
@@ -1113,7 +1207,7 @@ class JellyTauPlayer(private val appContext: Context) {
* This should be called from MainActivity when video playback is active. * This should be called from MainActivity when video playback is active.
*/ */
fun attachSurfaceToActivity(activity: android.app.Activity) { fun attachSurfaceToActivity(activity: android.app.Activity) {
if (surfaceView != null && currentMediaType == MediaType.VIDEO) { if (videoView != null && currentMediaType == MediaType.VIDEO) {
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity) com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity") android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
} }
@@ -1159,7 +1253,7 @@ class JellyTauPlayer(private val appContext: Context) {
*/ */
fun fitSurfaceToScreen() { fun fitSurfaceToScreen() {
mainHandler.post { mainHandler.post {
val view = surfaceView ?: return@post val view = videoView ?: return@post
val parent = view.parent as? ViewGroup val parent = view.parent as? ViewGroup
// Available area: prefer the parent's measured size, fall back to the screen. // Available area: prefer the parent's measured size, fall back to the screen.
val availW = parent?.width?.takeIf { it > 0 } val availW = parent?.width?.takeIf { it > 0 }
@@ -1191,6 +1285,20 @@ class JellyTauPlayer(private val appContext: Context) {
if (lp is FrameLayout.LayoutParams) { if (lp is FrameLayout.LayoutParams) {
lp.gravity = android.view.Gravity.CENTER lp.gravity = android.view.Gravity.CENTER
} }
// Deliberately no alpha-hiding across the resize.
//
// Two earlier attempts hid the view here (and from
// onConfigurationChanged) until a fresh frame landed, on the reading
// that the letterbox flash was a retained TextureView frame drawn at
// the old size. It was not: the bars were showing stale *framebuffer*
// content because nothing painted them — see the window-background
// note in MainActivity.setTransparent. Hiding the video view made
// that strictly worse, since the TextureView is the one view in the
// hierarchy that reliably paints its own rect; dropping its alpha to
// 0 simply widened the un-painted area.
//
// TRACES: UR-003, UR-066 | DR-194
lp.width = targetW lp.width = targetW
lp.height = targetH lp.height = targetH
view.layoutParams = lp view.layoutParams = lp
@@ -1203,14 +1311,24 @@ class JellyTauPlayer(private val appContext: Context) {
} }
/** /**
* Clear the video surface when switching to audio playback. * Clear the video surface when switching to audio playback, or on stop.
*
* Detaching is not optional bookkeeping: dropping the reference without
* removing the view left the SurfaceView parented to the content view for
* the life of the process, and the next video stacked another one under it.
* See VideoOverlayManager.detachVideoSurface.
*
* Always called on the main thread (every caller runs inside a
* `mainHandler.post`), which is what touching the view hierarchy requires.
*
* TRACES: UR-003, UR-041 | DR-184
*/ */
private fun clearVideoSurface() { private fun clearVideoSurface() {
surfaceView?.let { videoView?.let {
exoPlayer.clearVideoSurface() exoPlayer.clearVideoSurface()
surfaceView = null com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
surfaceHolder = null videoView = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared") android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
} }
} }
@@ -1218,7 +1336,12 @@ class JellyTauPlayer(private val appContext: Context) {
* Request audio focus for video playback. * Request audio focus for video playback.
* This is critical for video to have audio on Android. * This is critical for video to have audio on Android.
* *
* TRACES: UR-004 | DR-145 * The listener installed here is the pause-on-call path: AUDIOFOCUS_LOSS and
* AUDIOFOCUS_LOSS_TRANSIENT (an incoming call is the latter) both pause,
* LOSS_TRANSIENT_CAN_DUCK lowers the volume instead, and GAIN restores
* resuming only what we paused, via pendingPlayOnFocusGain.
*
* TRACES: UR-004, UR-006 | IR-008, DR-145
* *
* @return true if focus was granted outright and playback may start now. * @return true if focus was granted outright and playback may start now.
* false for a DELAYED or refused request the caller must hold playback * false for a DELAYED or refused request the caller must hold playback
@@ -100,9 +100,27 @@ class SecureStorage private constructor(context: Context) {
} }
} }
/**
* Read a credential.
*
* Returns null for both "nothing stored" and "stored but undecryptable", but
* treats them as distinct events. The second happens after a backup restore
* or a device-to-device transfer: SharedPreferences travel, the Android
* Keystore key that encrypted them never does, so the ciphertext can never
* be read again on this install. That blob is discarded here rather than
* left to fail on every subsequent read, which turns a permanently broken
* credential into a clean logged-out state. (The app also declares
* allowBackup="false" plus data-extraction rules so this should no longer
* arise - this is the belt to that manifest's braces.)
*/
fun getCredential(key: String): String? { fun getCredential(key: String): String? {
try { val encoded = prefs.getString(key, null)
val encoded = prefs.getString(key, null) ?: return null if (encoded == null) {
Log.d(TAG, "No credential stored for: $key")
return null
}
return try {
val combined = Base64.decode(encoded, Base64.DEFAULT) val combined = Base64.decode(encoded, Base64.DEFAULT)
// Extract IV (first 12 bytes for GCM) // Extract IV (first 12 bytes for GCM)
@@ -114,10 +132,16 @@ class SecureStorage private constructor(context: Context) {
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec) cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val decrypted = cipher.doFinal(encrypted) val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8) String(decrypted, Charsets.UTF_8)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to get credential: $key", e) Log.w(
return null TAG,
"Credential '$key' is present but cannot be decrypted; discarding it and " +
"reporting no credential. Signing in again will store a fresh one.",
e
)
prefs.edit().remove(key).apply()
null
} }
} }
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backup / transfer policy for JellyTau (API 31+; see android:allowBackup in
AndroidManifest.xml for API 24-30).
Nothing is eligible for extraction, from either channel:
* cloud-backup - already off via android:allowBackup="false".
* device-transfer - NOT covered by allowBackup on Android 12+, which is why
this file exists. A D2D transfer would otherwise copy the same data the
cloud backup used to.
Why nothing is extractable:
* Credentials are unrecoverable off-device. jellytau_secure_prefs holds
AES-GCM ciphertext encrypted under an Android Keystore key, and Keystore
keys are never backed up or transferred. Restoring the prefs without the
key produces ciphertext nothing can read - a silent auth failure that looks
like a broken app rather than a logged-out one.
* Everything else is a rebuildable cache. The SQLite catalogue is a mirror of
the Jellyfin server (library metadata, watch history, offline downloads);
signing in again reproduces it, and watch state lives on the server anyway.
Backing it up would export a user's library and viewing history to their
Google account for no gain.
Exclude rules are listed per domain rather than relying on "root" alone,
because database/, shared_prefs/, files/ and external storage are addressed
as their own domains by the extraction engine.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
@@ -12,7 +12,12 @@
remote server still has to be HTTPS — this must not become a blanket remote server still has to be HTTPS — this must not become a blanket
cleartext opt-in. cleartext opt-in.
TRACES: UR-071 | DR-138 This file is only half the policy. MainActivity.configureWebViewSettings sets
the webview's mixedContentMode and its file/content access flags; setting
MIXED_CONTENT_ALWAYS_ALLOW there re-opened by hand what this config closes,
which is DR-199. Change the two together, or not at all.
TRACES: UR-071 | DR-138, DR-199
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="false" /> <base-config cleartextTrafficPermitted="false" />
+33
View File
@@ -129,6 +129,18 @@ impl AuthManager {
Ok(normalized) Ok(normalized)
} }
/// Normalize a username before it goes to the server.
///
/// Only surrounding whitespace is stripped — interior spaces are legal in
/// Jellyfin usernames. Without this, a trailing space from a soft keyboard's
/// autocorrect makes the server report an unknown user, which surfaces as a
/// 401 that looks exactly like a wrong password.
///
/// TRACES: UR-042 | DR-054
pub fn normalize_username(username: &str) -> String {
username.trim().to_string()
}
/// Connect to server and get server info /// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> { pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url)?; let normalized_url = Self::normalize_url(server_url)?;
@@ -185,6 +197,7 @@ impl AuthManager {
) -> Result<AuthResult, String> { ) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url)?; let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/AuthenticateByName", url); let endpoint = format!("{}/Users/AuthenticateByName", url);
let username = Self::normalize_username(username);
log::info!("[AuthManager] Authenticating user: {}", username); log::info!("[AuthManager] Authenticating user: {}", username);
@@ -443,6 +456,26 @@ mod tests {
); );
} }
/// Usernames must be trimmed before they reach the server: the Android soft
/// keyboard appends a trailing space after autocorrect, and Jellyfin then
/// reports an unknown user — a 401 indistinguishable from a wrong password.
#[test]
fn test_normalize_username_trims_whitespace() {
assert_eq!(AuthManager::normalize_username("duncan "), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan"), "duncan");
assert_eq!(AuthManager::normalize_username(" duncan "), "duncan");
assert_eq!(AuthManager::normalize_username("duncan\n"), "duncan");
}
/// Interior spaces are legal in Jellyfin usernames and must survive.
#[test]
fn test_normalize_username_preserves_interior_spaces() {
assert_eq!(
AuthManager::normalize_username(" duncan tourolle "),
"duncan tourolle"
);
}
/// Test URL normalization - real world case /// Test URL normalization - real world case
#[test] #[test]
fn test_normalize_url_real_world_case() { fn test_normalize_url_real_world_case() {
+2 -2
View File
@@ -418,13 +418,13 @@ mod tests {
#[test] #[test]
fn test_auth_manager_wrapper_structure() { fn test_auth_manager_wrapper_structure() {
// Verify wrapper type exists and has correct structure // Verify wrapper type exists and has correct structure
assert_eq!(std::mem::size_of::<AuthManagerWrapper>() > 0, true); assert!(std::mem::size_of::<AuthManagerWrapper>() > 0);
} }
#[test] #[test]
fn test_session_verifier_wrapper_structure() { fn test_session_verifier_wrapper_structure() {
// Verify wrapper type exists and has correct structure // Verify wrapper type exists and has correct structure
assert_eq!(std::mem::size_of::<SessionVerifierWrapper>() > 0, true); assert!(std::mem::size_of::<SessionVerifierWrapper>() > 0);
} }
#[test] #[test]
+123 -30
View File
@@ -496,7 +496,7 @@ pub(crate) async fn requeue_mistyped_video_downloads(
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let query = Query::new(&format!( let query = Query::new(format!(
"UPDATE downloads "UPDATE downloads
SET status = 'pending', stream_url = NULL, progress = 0, SET status = 'pending', stream_url = NULL, progress = 0,
bytes_downloaded = 0, started_at = NULL, completed_at = NULL bytes_downloaded = 0, started_at = NULL, completed_at = NULL
@@ -520,15 +520,27 @@ pub(crate) async fn requeue_mistyped_video_downloads(
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning /// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it. /// `None` leaves the row pending), and heal the row so the pump can start it.
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`. /// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
///
/// `only_ids` restricts the sweep to specific download rows. Reconnect passes
/// `None` and heals everything; a bulk enqueue (an album, say) passes the rows
/// it just created, so clicking download on one album cannot also start every
/// unrelated row that has been sitting pending.
pub(crate) async fn resolve_pending_download_urls<F, Fut>( pub(crate) async fn resolve_pending_download_urls<F, Fut>(
db_service: &Arc<crate::storage::db_service::RusqliteService>, db_service: &Arc<crate::storage::db_service::RusqliteService>,
target_dir: &str, target_dir: &str,
only_ids: Option<&[i64]>,
resolve: F, resolve: F,
) -> Result<ResumeQueuedResult, String> ) -> Result<ResumeQueuedResult, String>
where where
F: Fn(String, String, String) -> Fut, F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>, Fut: std::future::Future<Output = Option<String>>,
{ {
if only_ids.is_some_and(|ids| ids.is_empty()) {
return Ok(ResumeQueuedResult {
resolved: 0,
failed: 0,
});
}
// A row's own media_type wins; otherwise the *item's* type decides. Rows // A row's own media_type wins; otherwise the *item's* type decides. Rows
// queued from a media card never carry one (`download_item` does not record // queued from a media card never carry one (`download_item` does not record
// it), and defaulting that NULL to 'audio' resolved movies against // it), and defaulting that NULL to 'audio' resolved movies against
@@ -541,7 +553,17 @@ where
.map(|t| format!("'{t}'")) .map(|t| format!("'{t}'"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let rows_query = Query::new(&format!( let id_filter = match only_ids {
Some(ids) => format!(
" AND d.id IN ({})",
ids.iter()
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ")
),
None => String::new(),
};
let rows_query = Query::new(format!(
"SELECT d.id, d.item_id, "SELECT d.id, d.item_id,
COALESCE( COALESCE(
d.media_type, d.media_type,
@@ -552,7 +574,7 @@ where
COALESCE(d.quality_preset, 'original') COALESCE(d.quality_preset, 'original')
FROM downloads d FROM downloads d
LEFT JOIN items i ON i.id = d.item_id LEFT JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending' AND d.stream_url IS NULL" WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
)); ));
let rows: Vec<(i64, String, String, String)> = db_service let rows: Vec<(i64, String, String, String)> = db_service
.query_many(rows_query, |row| { .query_many(rows_query, |row| {
@@ -631,8 +653,6 @@ pub async fn resume_queued_downloads(
) -> Result<ResumeQueuedResult, String> { ) -> Result<ResumeQueuedResult, String> {
use crate::repository::MediaRepository; use crate::repository::MediaRepository;
use crate::repository::HybridRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?; let repo = repository.0.get(&handle).ok_or("Repository not found")?;
// The pump needs a target_dir; use the same storage root the other download // The pump needs a target_dir; use the same storage root the other download
@@ -678,17 +698,19 @@ pub async fn resume_queued_downloads(
let outcome = resolve_pending_download_urls( let outcome = resolve_pending_download_urls(
&db_service, &db_service,
&target_dir, &target_dir,
None,
move |item_id: String, media_type: String, quality: String| { move |item_id: String, media_type: String, quality: String| {
let repo = Arc::clone(&repo_for_resolve); let repo = Arc::clone(&repo_for_resolve);
async move { async move {
if media_type == "video" { if media_type == "video" {
Some( Some(
<HybridRepository as MediaRepository>::get_video_download_url( crate::repository::resolve_video_download_url(
repo.as_ref(), repo.as_ref(),
&item_id, &item_id,
&quality, &quality,
None, None,
), )
.await,
) )
} else { } else {
match repo.get_audio_stream_url(&item_id).await { match repo.get_audio_stream_url(&item_id).await {
@@ -731,6 +753,7 @@ pub async fn resume_queued_downloads(
mod tests { mod tests {
use super::*; use super::*;
use crate::storage::db_service::RusqliteService; use crate::storage::db_service::RusqliteService;
use crate::utils::lock::MutexSafe;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::Mutex; use std::sync::Mutex;
@@ -862,12 +885,14 @@ mod tests {
// A completed row: irrelevant. // A completed row: irrelevant.
insert_download(&db, "done", "completed", Some("http://done/url"), None).await; insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
let out = let out = resolve_pending_download_urls(
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move { &db,
Some(format!("http://resolved/{item_id}")) "/data/downloads",
}) None,
.await |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
.unwrap(); )
.await
.unwrap();
assert_eq!(out.resolved, 1); assert_eq!(out.resolved, 1);
assert_eq!(out.failed, 0); assert_eq!(out.failed, 0);
@@ -883,15 +908,79 @@ mod tests {
assert_eq!(url2.as_deref(), Some("http://existing/url")); assert_eq!(url2.as_deref(), Some("http://existing/url"));
} }
/// A bulk enqueue resolves only the rows it just created. Downloading one
/// album must not also start every unrelated row that has been sitting
/// pending with no URL (the smart cache leaves plenty of those).
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
#[tokio::test]
async fn only_ids_restricts_the_sweep_to_the_given_rows() {
let db = test_db();
insert_download(&db, "mine", "pending", None, Some("audio")).await;
insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
let mine: i64 = db
.query_one(
Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
|row| row.get(0),
)
.await
.unwrap();
let out = resolve_pending_download_urls(
&db,
"/data",
Some(&[mine]),
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
)
.await
.unwrap();
assert_eq!(out.resolved, 1);
assert_eq!(out.failed, 0);
let (_s, url, _t) = get_row(&db, "mine").await;
assert_eq!(url.as_deref(), Some("http://resolved/mine"));
let (status, other_url, _t) = get_row(&db, "someone-elses").await;
assert_eq!(status, "pending");
assert_eq!(
other_url, None,
"a scoped resolve must leave unrelated pending rows alone"
);
}
/// An empty id list resolves nothing — it must not fall through to "sweep
/// everything", which is what an unguarded `IN ()` would amount to.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-171
#[tokio::test]
async fn an_empty_id_list_resolves_nothing() {
let db = test_db();
insert_download(&db, "untouched", "pending", None, Some("audio")).await;
let out =
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
})
.await
.unwrap();
assert_eq!(out.resolved, 0);
let (_s, url, _t) = get_row(&db, "untouched").await;
assert_eq!(url, None);
}
#[tokio::test] #[tokio::test]
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() { async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
let db = test_db(); let db = test_db();
insert_download(&db, "bad", "pending", None, None).await; insert_download(&db, "bad", "pending", None, None).await;
// Resolver returns None (e.g. server lookup failed). // Resolver returns None (e.g. server lookup failed).
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None }) let out =
.await resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
.unwrap(); .await
.unwrap();
assert_eq!(out.resolved, 0); assert_eq!(out.resolved, 0);
assert_eq!(out.failed, 1); assert_eq!(out.failed, 1);
@@ -921,17 +1010,17 @@ mod tests {
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));
let seen_c = Arc::clone(&seen); let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| { resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
let seen = Arc::clone(&seen_c); let seen = Arc::clone(&seen_c);
async move { async move {
seen.lock().unwrap().push((item_id.clone(), media_type)); seen.lock_safe().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}")) Some(format!("http://resolved/{item_id}"))
} }
}) })
.await .await
.unwrap(); .unwrap();
let seen = seen.lock().unwrap().clone(); let seen = seen.lock_safe().clone();
let of = |id: &str| { let of = |id: &str| {
seen.iter() seen.iter()
.find(|(i, _)| i == id) .find(|(i, _)| i == id)
@@ -954,17 +1043,17 @@ mod tests {
let seen = Arc::new(Mutex::new(String::new())); let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen); let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| { resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c); let seen = Arc::clone(&seen_c);
async move { async move {
*seen.lock().unwrap() = media_type; *seen.lock_safe() = media_type;
Some("http://x".to_string()) Some("http://x".to_string())
} }
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(*seen.lock().unwrap(), "audio"); assert_eq!(*seen.lock_safe(), "audio");
} }
/// An explicit `media_type` on the row always wins over the item's type. /// An explicit `media_type` on the row always wins over the item's type.
@@ -978,17 +1067,17 @@ mod tests {
let seen = Arc::new(Mutex::new(String::new())); let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen); let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| { resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c); let seen = Arc::clone(&seen_c);
async move { async move {
*seen.lock().unwrap() = media_type; *seen.lock_safe() = media_type;
Some("http://x".to_string()) Some("http://x".to_string())
} }
}) })
.await .await
.unwrap(); .unwrap();
assert_eq!(*seen.lock().unwrap(), "video"); assert_eq!(*seen.lock_safe(), "video");
} }
/// Rows already downloaded under the audio default hold an audio-only /// Rows already downloaded under the audio default hold an audio-only
@@ -1038,13 +1127,17 @@ mod tests {
let db = test_db(); let db = test_db();
insert_download(&db, "vid-1", "pending", None, Some("video")).await; insert_download(&db, "vid-1", "pending", None, Some("video")).await;
let out = let out = resolve_pending_download_urls(
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move { &db,
"/data",
None,
|item_id, media_type, _q| async move {
assert_eq!(media_type, "video"); assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}")) Some(format!("http://transcode/{item_id}"))
}) },
.await )
.unwrap(); .await
.unwrap();
assert_eq!(out.resolved, 1); assert_eq!(out.resolved, 1);
let (_s, url, _t) = get_row(&db, "vid-1").await; let (_s, url, _t) = get_row(&db, "vid-1").await;
+1 -1
View File
@@ -97,6 +97,6 @@ mod tests {
// due to its dependencies, so we just test the wrapper type structure // due to its dependencies, so we just test the wrapper type structure
// This verifies the wrapper type exists and can hold Arc<Mutex> // This verifies the wrapper type exists and can hold Arc<Mutex>
assert_eq!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0, true); assert!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0);
} }
} }
+708 -58
View File
@@ -19,6 +19,10 @@ mod smart_cache;
pub use pinning::*; pub use pinning::*;
pub use smart_cache::*; pub use smart_cache::*;
/// One row of the series episode listing used when queueing a whole series:
/// `(id, name, season_name, index_number, parent_index_number)`.
type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
/// Wrapper for DownloadManager to be used as Tauri state /// Wrapper for DownloadManager to be used as Tauri state
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>); pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
@@ -350,57 +354,209 @@ pub async fn download_item(
Ok(download_id) Ok(download_id)
} }
/// Queue an entire album for download /// One track of an album, as the album-download path queues it.
#[tauri::command] ///
#[specta::specta] /// `artist_name` carries whatever the catalog holds for the track's artists (a
pub async fn download_album( /// JSON array, as stored on `items.artists`); it is display metadata for the
db: State<'_, DatabaseWrapper>, /// downloads list, not a lookup key.
album_id: String, ///
user_id: String, /// TRACES: UR-018, UR-055 | DR-173
base_path: String, #[derive(Debug, Clone, PartialEq)]
) -> Result<Vec<i64>, String> { pub(crate) struct AlbumTrack {
let db_service = { pub id: String,
let database = db.0.lock().map_err(|e| e.to_string())?; pub name: String,
Arc::new(database.service()) pub artist_name: Option<String>,
}; pub album_name: Option<String>,
pub index_number: Option<i32>,
}
// Get all tracks in the album with metadata impl From<&crate::repository::types::MediaItem> for AlbumTrack {
fn from(item: &crate::repository::types::MediaItem) -> Self {
Self {
id: item.id.clone(),
name: item.name.clone(),
artist_name: item
.artists
.as_ref()
.and_then(|a| serde_json::to_string(a).ok()),
album_name: item.album_name.clone(),
index_number: item.index_number,
}
}
}
/// The album's tracks as the local catalog cache knows them.
///
/// Only a fallback for [`download_album`]: the cache links a track to its album
/// through `items.album_id`, which Jellyfin does not populate on every listing
/// endpoint, so this can legitimately return fewer tracks than the album has.
///
/// TRACES: UR-018, UR-055 | DR-173
pub(crate) async fn cached_album_tracks(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
album_id: &str,
) -> Result<Vec<AlbumTrack>, String> {
let tracks_query = Query::with_params( let tracks_query = Query::with_params(
"SELECT id, name, artists, album_name FROM items "SELECT id, name, artists, album_name, index_number FROM items
WHERE album_id = ? AND item_type = 'Audio' WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
ORDER BY index_number", ORDER BY index_number",
vec![QueryParam::String(album_id)], vec![
QueryParam::String(album_id.to_string()),
QueryParam::String(album_id.to_string()),
],
); );
let tracks: Vec<(String, String, Option<String>, Option<String>)> = db_service db_service
.query_many(tracks_query, |row| { .query_many(tracks_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) Ok(AlbumTrack {
id: row.get(0)?,
name: row.get(1)?,
artist_name: row.get(2)?,
album_name: row.get(3)?,
index_number: row.get(4)?,
})
}) })
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())
}
let mut download_ids = Vec::new(); /// Queue one download row per track and link every track to its album.
///
/// The linkage is the half that is easy to miss: offline browsing joins a track
/// to its album on `items.album_id` (see `OfflineRepository::get_items`), so a
/// track whose cached row lacks it stays invisible under the album even after
/// its file is on disk. Queuing a track *is* the statement that it belongs to
/// this album, so the link is written here rather than hoped for from whichever
/// listing endpoint happened to cache the row.
///
/// Idempotent: re-queuing an album fills in what is missing and returns the same
/// row ids, in the order the tracks were given.
///
/// A file name per track, unique within the album.
///
/// A title is not a unique name inside its own album: a deluxe edition carries
/// the album version and a demo of the same song, and a two-disc set repeats
/// titles across discs. Naming files after the title alone gave those tracks one
/// path, and each download overwrote the previous one — an album that quietly
/// ends up short by however many titles it repeats. The track number
/// disambiguates the ordinary case; anything still colliding falls back to the
/// item id, which is unique by construction.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for track in tracks {
*counts.entry(track.name.to_lowercase()).or_default() += 1;
}
// Queue each track with album priority (100) and metadata tracks
for (track_id, track_name, artist_name, album_name) in tracks { .iter()
let file_path = format!("{}/{}.mp3", base_path, sanitize_filename(&track_name)); .map(|track| {
let title = sanitize_filename(&track.name);
if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
return format!("{}.mp3", title);
}
match track.index_number {
Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
None => format!("{} [{}].mp3", title, track.id),
}
})
.collect()
}
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
pub(crate) async fn queue_album_tracks(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
album_id: &str,
tracks: &[AlbumTrack],
user_id: &str,
base_path: &str,
) -> Result<Vec<i64>, String> {
let mut download_ids = Vec::with_capacity(tracks.len());
let file_names = album_file_names(tracks);
for (track, file_name) in tracks.iter().zip(file_names) {
// Cache a row for a track the catalog has never seen, borrowing the
// album's server. Nothing is inserted when the album itself is unknown,
// which also keeps the parent_id foreign key satisfiable.
let cache_query = Query::with_params(
"INSERT OR IGNORE INTO items
(id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
FROM items a WHERE a.id = ?",
vec![
QueryParam::String(track.id.clone()),
QueryParam::String(track.name.clone()),
track
.album_name
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
track
.artist_name
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
track
.index_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
QueryParam::String(album_id.to_string()),
],
);
db_service
.execute(cache_query)
.await
.map_err(|e| e.to_string())?;
// Link an already-cached track to the album. The parent_id subquery
// resolves to NULL when the album is not cached, so the foreign key
// holds either way.
let link_query = Query::with_params(
"UPDATE items
SET album_id = ?,
parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
WHERE id = ?",
vec![
QueryParam::String(album_id.to_string()),
QueryParam::String(album_id.to_string()),
QueryParam::String(track.id.clone()),
],
);
db_service
.execute(link_query)
.await
.map_err(|e| e.to_string())?;
let file_path = format!("{}/{}", base_path, file_name);
// Queue at album priority (100). A track already downloaded stays
// completed — re-queuing an album must fill the gaps, not re-fetch it.
let insert_query = Query::with_params( let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name) "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?) VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
ON CONFLICT(item_id, user_id) DO UPDATE SET ON CONFLICT(item_id, user_id) DO UPDATE SET
priority = 100, priority = 100,
status = 'pending', status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
media_type = 'audio',
item_name = COALESCE(excluded.item_name, downloads.item_name), item_name = COALESCE(excluded.item_name, downloads.item_name),
artist_name = COALESCE(excluded.artist_name, downloads.artist_name), artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
album_name = COALESCE(excluded.album_name, downloads.album_name)", album_name = COALESCE(excluded.album_name, downloads.album_name)",
vec![ vec![
QueryParam::String(track_id.clone()), QueryParam::String(track.id.clone()),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.to_string()),
QueryParam::String(file_path), QueryParam::String(file_path),
QueryParam::String(track_name), QueryParam::String(track.name.clone()),
artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null), track
album_name.map(QueryParam::String).unwrap_or(QueryParam::Null), .artist_name
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
track
.album_name
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
], ],
); );
@@ -413,8 +569,8 @@ pub async fn download_album(
let id_query = Query::with_params( let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?", "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![ vec![
QueryParam::String(track_id), QueryParam::String(track.id.clone()),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.to_string()),
], ],
); );
@@ -428,6 +584,133 @@ pub async fn download_album(
Ok(download_ids) Ok(download_ids)
} }
/// Queue an entire album for download.
///
/// Owns the whole operation: the album's track list comes from the server (the
/// only place that knows all of it), every track is queued and linked to its
/// album, each row's stream URL is resolved here, and the queue is pumped.
///
/// The frontend used to do the second half — resolve one URL per track and pair
/// it with the returned ids **by position**. That pairing had no basis: the ids
/// came back in the backend's own order over a different set of rows, so
/// whenever the two lists disagreed a row was handed another track's URL, and
/// any track past the end of the shorter list was never started at all. Nothing
/// crosses the boundary now except the album id.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tauri::command]
#[specta::specta]
// Three of the eight arguments are Tauri `State<'_, _>` injections plus the
// `AppHandle`, not caller input. Folding the rest into a struct would change the
// IPC contract and the generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn download_album(
db: State<'_, DatabaseWrapper>,
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
handle: String,
album_id: String,
user_id: String,
base_path: String,
) -> Result<Vec<i64>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let repo = repository.0.get(&handle);
// Ask the server what the album contains; the cache is only a fallback for
// when it cannot answer.
let tracks: Vec<AlbumTrack> = match &repo {
Some(repo) => match repo.get_album_tracks(&album_id).await {
Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
Err(e) => {
warn!(
"[download_album] Could not list album {} from the repository ({:?}); \
falling back to the cached track list",
album_id, e
);
cached_album_tracks(&db_service, &album_id).await?
}
},
None => cached_album_tracks(&db_service, &album_id).await?,
};
if tracks.is_empty() {
warn!("[download_album] No tracks found for album {}", album_id);
return Ok(Vec::new());
}
let download_ids =
queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
info!(
"[download_album] Queued {} track(s) for album {}",
download_ids.len(),
album_id
);
// Resolve each queued row's stream URL here, then pump. Without a
// repository (or while offline) the rows stay pending with no URL and
// `resume_queued_downloads` picks them up on reconnect.
let Some(repo) = repo else {
return Ok(download_ids);
};
let target_dir = {
let database = db.0.lock().map_err(|e| e.to_string())?;
database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string()
};
let repo_for_resolve = Arc::clone(&repo);
let outcome = crate::commands::catalog::resolve_pending_download_urls(
&db_service,
&target_dir,
Some(&download_ids),
move |item_id: String, _media_type: String, _quality: String| {
let repo = Arc::clone(&repo_for_resolve);
async move {
use crate::repository::MediaRepository;
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Err(e) => {
warn!(
"[download_album] Failed to resolve stream URL for {}: {:?}",
item_id, e
);
None
}
}
}
},
)
.await?;
if outcome.failed > 0 {
warn!(
"[download_album] {} track(s) could not be resolved and stay queued for the next \
reconnect",
outcome.failed
);
}
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(download_ids)
}
/// Queue a video item (movie or episode) for download with quality preset /// Queue a video item (movie or episode) for download with quality preset
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -532,7 +815,7 @@ pub async fn download_series(
vec![QueryParam::String(series_id)], vec![QueryParam::String(series_id)],
); );
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service let episodes: Vec<EpisodeRow> = db_service
.query_many(episodes_query, |row| { .query_many(episodes_query, |row| {
Ok(( Ok((
row.get(0)?, row.get(0)?,
@@ -637,6 +920,10 @@ pub async fn download_series(
/// Queue all episodes of a specific season for download /// Queue all episodes of a specific season for download
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
// One of the eight arguments is a Tauri `State<'_, _>` injection; the rest are
// the season's identifying fields. Folding them into a struct would change the
// IPC contract and the generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn download_season( pub async fn download_season(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
season_id: String, season_id: String,
@@ -845,7 +1132,19 @@ pub async fn get_downloads(
Ok(DownloadsResponse { downloads, stats }) Ok(DownloadsResponse { downloads, stats })
} }
/// Pause a download /// Pause a download.
///
/// Writing `status = 'paused'` is only half of it, and used to be all of it: the
/// streaming task knew nothing about the row and kept running, then overwrote it
/// with `completed`/`failed` when it finished. The row flicked to "paused" and
/// undid itself — the reported "pause does not work". Signalling the worker is
/// what actually stops the bytes; it leaves the `.part` file in place so
/// [`resume_download`] can continue from it.
///
/// A queued (not yet started) download has no worker to signal, and the status
/// write alone is enough — the pump skips anything that is not `pending`.
///
/// TRACES: UR-055 | DR-168
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn pause_download( pub async fn pause_download(
@@ -858,19 +1157,34 @@ pub async fn pause_download(
}; };
let query = Query::with_params( let query = Query::with_params(
"UPDATE downloads SET status = 'paused' WHERE id = ? AND status = 'downloading'", "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
vec![QueryParam::Int64(download_id)], vec![QueryParam::Int64(download_id)],
); );
db_service.execute(query).await.map_err(|e| e.to_string())?; db_service.execute(query).await.map_err(|e| e.to_string())?;
let was_running = crate::download::stop::signal(download_id);
info!(
"[pause] Download {} paused (in flight: {})",
download_id, was_running
);
Ok(()) Ok(())
} }
/// Resume a paused download /// Resume a paused download.
///
/// Flipping the row back to `pending` is likewise not enough on its own: the
/// pump is not a poller, it runs when something calls it, so a resumed download
/// sat untouched until some unrelated event happened to pump the queue. That is
/// the other half of "resume does not work".
///
/// TRACES: UR-055 | DR-168
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn resume_download( pub async fn resume_download(
app: tauri::AppHandle,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
download_id: i64, download_id: i64,
) -> Result<(), String> { ) -> Result<(), String> {
let db_service = { let db_service = {
@@ -879,11 +1193,22 @@ pub async fn resume_download(
}; };
let query = Query::with_params( let query = Query::with_params(
"UPDATE downloads SET status = 'pending' WHERE id = ? AND status = 'paused'", "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
vec![QueryParam::Int64(download_id)], vec![QueryParam::Int64(download_id)],
); );
db_service.execute(query).await.map_err(|e| e.to_string())?; db_service.execute(query).await.map_err(|e| e.to_string())?;
// Drop any stale stop flag before the pump can start this id again, or the
// resumed run would read the pause that stopped it and halt immediately.
crate::download::stop::clear(download_id);
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(()) Ok(())
} }
@@ -923,6 +1248,13 @@ pub async fn cancel_download(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Stop the worker if this download is actually running. Without this the
// task keeps streaming into a `.part` file whose `downloads` row has just
// been deleted — bytes with nothing pointing at them, and the file below is
// removed while still being written to. (DR-168)
crate::download::stop::signal(download_id);
crate::download::stop::clear(download_id);
// Unregister from download manager (in case it was active) // Unregister from download manager (in case it was active)
{ {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?; let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
@@ -934,10 +1266,12 @@ pub async fn cancel_download(
); );
} }
// Delete partial file if exists // Delete the partial file, and any completed file, if present. Both go
// through `partial_path` so this cannot drift from what the worker writes —
// it did, and every cancelled download leaked its partial. (DR-169)
if let Some(path) = file_path { if let Some(path) = file_path {
let partial_path = format!("{}.part", path); let target = std::path::PathBuf::from(&path);
let _ = std::fs::remove_file(&partial_path); // Ignore errors let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
} }
Ok(()) Ok(())
@@ -1244,8 +1578,6 @@ pub async fn enqueue_video_downloads(
download_ids: Vec<i64>, download_ids: Vec<i64>,
target_dir: String, target_dir: String,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?; let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = { let db_service = {
@@ -1270,10 +1602,12 @@ pub async fn enqueue_video_downloads(
} }
}; };
// Build the transcode URL (pure URL builder, no server round-trip). // Build the download URL, resolving the source's audio codec first so a
let stream_url = repo // track this device cannot decode is re-encoded on the way down rather
.as_ref() // than saved as a silent file (DR-167).
.get_video_download_url(&item_id, &quality, None); let stream_url =
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
.await;
let update_query = Query::with_params( let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?", "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
@@ -1530,7 +1864,11 @@ fn spawn_download_worker(
let _ = progress_app.emit("download-event", event); let _ = progress_app.emit("download-event", event);
}; };
let result = worker.download(&task, on_progress).await; // Registering returns a fresh flag, so a download resumed after a pause
// does not inherit the stop that ended its previous run. (DR-168)
let stop_flag = crate::download::stop::register(download_id);
let result = worker.download(&task, &stop_flag, on_progress).await;
crate::download::stop::clear(download_id);
// Free the slot before pumping so the next download can take it. // Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() { if let Ok(mut active) = active_downloads.lock() {
@@ -1601,6 +1939,17 @@ fn spawn_download_worker(
Err(e) => error!(" Completed event emit failed: {:?}", e), Err(e) => error!(" Completed event emit failed: {:?}", e),
} }
} }
// A pause or cancel is not a failure. The row already says `paused`
// (or the row is gone, for a cancel), and overwriting that with
// `failed` is what made a pause look like an error and stranded the
// download outside the resumable set. The `.part` file is deliberately
// left alone — it is what the resume continues from. (DR-168)
Err(e) if e.is_stopped() => {
info!(
"[pump] Download {} stopped by request; partial file kept for resume",
download_id
);
}
Err(e) => { Err(e) => {
error!("Download failed: {:?}", e); error!("Download failed: {:?}", e);
@@ -1848,17 +2197,26 @@ pub async fn clear_stale_downloads(
Arc::new(database.service()) Arc::new(database.service())
}; };
// Get file paths for stale downloads (pending/paused/failed) // Ids as well as paths: a stale row may still have a worker attached (a
// 'downloading' row that was paused mid-flight is 'paused' here), and
// deleting the row without stopping the task leaves it writing to a file we
// are about to remove. (DR-168)
let file_query = Query::with_params( let file_query = Query::with_params(
"SELECT file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
vec![QueryParam::String(user_id.clone())], vec![QueryParam::String(user_id.clone())],
); );
let file_paths: Vec<String> = db_service let stale: Vec<(i64, String)> = db_service
.query_many(file_query, |row| row.get(0)) .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
for (id, _) in &stale {
crate::download::stop::signal(*id);
crate::download::stop::clear(*id);
}
let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
// Delete all pending, paused, and failed downloads (but keep completed ones) // Delete all pending, paused, and failed downloads (but keep completed ones)
let delete_query = Query::with_params( let delete_query = Query::with_params(
"DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')", "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
@@ -1870,10 +2228,12 @@ pub async fn clear_stale_downloads(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Delete any partial files // Delete any partial files, via the shared helper so this cannot drift from
// what the worker actually writes. (DR-169)
for path in file_paths { for path in file_paths {
let _ = std::fs::remove_file(&path); let target = std::path::PathBuf::from(&path);
let _ = std::fs::remove_file(format!("{}.part", path)); let _ = std::fs::remove_file(&target);
let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
} }
Ok(deleted_count as i64) Ok(deleted_count as i64)
@@ -1959,7 +2319,7 @@ pub async fn delete_downloads_under(
)"; )";
let file_query = Query::with_params( let file_query = Query::with_params(
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"), format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
vec![ vec![
QueryParam::String(user_id.clone()), QueryParam::String(user_id.clone()),
QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()),
@@ -1975,7 +2335,7 @@ pub async fn delete_downloads_under(
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let delete_query = Query::with_params( let delete_query = Query::with_params(
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"), format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
vec![ vec![
QueryParam::String(user_id), QueryParam::String(user_id),
QueryParam::String(item_id.clone()), QueryParam::String(item_id.clone()),
@@ -2585,4 +2945,294 @@ mod tests {
download_source: "user".to_string(), download_source: "user".to_string(),
} }
} }
// ===== Album download: track sourcing and album linkage =====
/// A database with just the tables the album-download path touches.
fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE items (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
parent_id TEXT,
name TEXT NOT NULL,
item_type TEXT NOT NULL,
album_id TEXT,
album_name TEXT,
album_artist TEXT,
artists TEXT,
index_number INTEGER
);
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
user_id TEXT NOT NULL,
file_path TEXT NOT NULL,
status TEXT DEFAULT 'pending',
priority INTEGER DEFAULT 0,
progress REAL DEFAULT 0,
queued_at TEXT,
item_name TEXT,
artist_name TEXT,
album_name TEXT,
media_type TEXT,
stream_url TEXT,
target_dir TEXT,
UNIQUE(item_id, user_id)
);
INSERT INTO items (id, server_id, name, item_type)
VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
"#,
)
.unwrap();
Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
Mutex::new(conn),
)))
}
fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
AlbumTrack {
id: id.to_string(),
name: name.to_string(),
artist_name: Some("Woodkid".to_string()),
album_name: Some("The Golden Age".to_string()),
index_number: Some(index),
}
}
/// The album-download regression: every track the album actually has must be
/// queued, and each queued track must be linked to its album.
///
/// `download_album` used to take its track list from
/// `items WHERE album_id = ?`. Jellyfin does not return `AlbumId` on every
/// listing endpoint, so tracks cached from those endpoints sit in `items`
/// with a NULL `album_id` — invisible to that query. "Download album" then
/// silently queued only the subset that happened to carry the link, which is
/// the reported "only 4-5 songs downloaded". The same column is what offline
/// browsing joins tracks to their album on (`i.album_id = ?` in
/// `OfflineRepository::get_items`), so even a track that did download stayed
/// invisible under its album offline.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tokio::test]
async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
let db = album_test_db();
// The cache holds all three tracks, but only one carries `album_id` —
// exactly the state the bug report's database is in.
for sql in [
"INSERT INTO items (id, server_id, name, item_type, album_id) \
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
"INSERT INTO items (id, server_id, name, item_type, album_id) \
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
"INSERT INTO items (id, server_id, name, item_type, album_id) \
VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
] {
db.execute(Query::new(sql)).await.unwrap();
}
let tracks = vec![
album_track("t1", "Run Boy Run", 1),
album_track("t2", "The Great Escape", 2),
album_track("t3", "Boat Song", 3),
];
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
.await
.unwrap();
assert_eq!(
ids.len(),
3,
"every track of the album must get a download row"
);
let queued: i64 = db
.query_one(
Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(queued, 3);
// Each track is now linked to its album, so the offline album page can
// find it once the download completes.
let linked: i64 = db
.query_one(
Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(
linked, 3,
"queued tracks must be linked to their album; offline browsing joins on album_id"
);
}
/// The returned ids must line up with the tracks that were passed in. The
/// frontend used to pair `downloadIds[i]` with its own `tracks[i]`, which is
/// only sound if both lists agree — they did not, because the backend
/// ordered by `index_number` over a different set of rows. Resolving URLs in
/// Rust removes the pairing entirely, but the order is still the contract
/// for anything that reads the ids back.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tokio::test]
async fn test_queue_album_tracks_returns_ids_in_track_order() {
let db = album_test_db();
let tracks = vec![
album_track("t1", "Run Boy Run", 1),
album_track("t2", "The Great Escape", 2),
];
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
.await
.unwrap();
for (id, track) in ids.iter().zip(tracks.iter()) {
let item_id: String = db
.query_one(
Query::with_params(
"SELECT item_id FROM downloads WHERE id = ?",
vec![QueryParam::Int64(*id)],
),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
}
}
/// Re-queueing an album already partly downloaded must not duplicate rows or
/// reset a completed track — it fills in what is missing.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tokio::test]
async fn test_queue_album_tracks_is_idempotent() {
let db = album_test_db();
let tracks = vec![
album_track("t1", "Run Boy Run", 1),
album_track("t2", "The Great Escape", 2),
];
let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
.await
.unwrap();
let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
.await
.unwrap();
assert_eq!(first, second, "the same tracks must map to the same rows");
let rows: i64 = db
.query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
row.get(0)
})
.await
.unwrap();
assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
}
/// Two tracks of one album can share a title — a deluxe edition carrying the
/// album version and a demo of the same song, or the same song on two discs.
/// Naming the file after the title alone gave them one path, so the second
/// download overwrote the first and the album ended up short however many
/// duplicates it had.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
#[test]
fn test_album_file_names_are_unique_within_the_album() {
let tracks = vec![
album_track("t1", "Crucified Again", 5),
album_track("t2", "Crucified Again", 5),
album_track("t3", "Get Right", 7),
];
let names = album_file_names(&tracks);
assert_eq!(names.len(), 3);
let unique: std::collections::HashSet<_> = names.iter().collect();
assert_eq!(
unique.len(),
3,
"every track of an album needs its own file: {:?}",
names
);
assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
assert!(
names[2].contains("Get Right"),
"an unambiguous title keeps its name: {}",
names[2]
);
}
/// Path separators in a track title must not escape the album directory.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-172
#[test]
fn test_album_file_names_sanitize_the_title() {
let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
assert!(!names[0].contains('/'), "{}", names[0]);
assert!(!names[0].contains(':'), "{}", names[0]);
}
/// The offline fallback reads the catalog directly, not through the
/// availability-gated offline listing: queueing an album while the server is
/// unreachable is a supported flow (the rows resolve on reconnect), and
/// gating it on what is already downloaded would queue only the tracks the
/// device already has.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tokio::test]
async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
let db = album_test_db();
for sql in [
"INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
// Linked by parent_id only — how a track cached from a folder
// listing lands in the catalog.
"INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
// A different album's track must not be swept in.
"INSERT INTO items (id, server_id, name, item_type, album_id) \
VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
] {
db.execute(Query::new(sql)).await.unwrap();
}
let tracks = cached_album_tracks(&db, "album1").await.unwrap();
let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["t1", "t2"]);
}
/// Tracks the cache has never seen still get queued: the row is created and
/// an `items` row is written for it, so the download is both startable and
/// visible offline afterwards.
///
/// TRACES: UR-018, UR-055 | DR-173 | UT-170
#[tokio::test]
async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
let db = album_test_db();
let tracks = vec![album_track("never-cached", "Iron", 1)];
let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
.await
.unwrap();
assert_eq!(ids.len(), 1);
let (item_type, album_id): (String, Option<String>) = db
.query_one(
Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
|row| Ok((row.get(0)?, row.get(1)?)),
)
.await
.unwrap();
assert_eq!(item_type, "Audio");
assert_eq!(album_id.as_deref(), Some("album1"));
}
} }
+2 -1
View File
@@ -186,6 +186,7 @@ async fn run_drain(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::utils::lock::MutexSafe;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::Mutex; use std::sync::Mutex;
@@ -211,7 +212,7 @@ mod tests {
} }
fn calls(&self) -> Vec<(String, bool)> { fn calls(&self) -> Vec<(String, bool)> {
let mut calls = self.calls.lock().unwrap().clone(); let mut calls = self.calls.lock_safe().clone();
calls.sort(); calls.sort();
calls calls
} }
+1 -1
View File
@@ -360,7 +360,7 @@ mod tests {
#[test] #[test]
fn test_playback_reporter_wrapper_structure() { fn test_playback_reporter_wrapper_structure() {
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>> // Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true); assert!(std::mem::size_of::<PlaybackReporterWrapper>() > 0);
} }
#[test] #[test]
+331 -39
View File
@@ -342,6 +342,29 @@ pub enum AudioTrackSwitchResponse {
}, },
} }
/// Response for a mid-playback streaming-quality change.
///
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
/// has to reload anything, so no strategy branch lives in the UI.
///
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse {
/// The native backend was reloaded here; nothing left for the frontend.
Native {
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this URL.
ReloadStream {
/// New stream URL, already transcoded to the requested ceiling.
new_url: String,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request /// Helper function to create MediaItem from video request
/// ///
/// PlayItemRequest is now video-only, so we create a video MediaItem. /// PlayItemRequest is now video-only, so we create a video MediaItem.
@@ -431,6 +454,49 @@ pub(super) fn background_audio_source(
} }
} }
/// How a background-audio handoff must start playback, given where its audio
/// actually begins.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) struct BackgroundAudioPlan {
/// The position the stream's own zero corresponds to, recorded as the
/// handoff base so later readings can be shifted back to the episode's
/// timeline.
pub base_seconds: f64,
/// Where to seek after loading, if the source does not already start there.
pub seek_to: Option<f64>,
}
/// Decide the base and the seek for a handoff at `position_seconds`.
///
/// The two sources start in different places. An audio-only **stream** is built
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
/// zero: the base is the handoff position, and seeking would skip *past* the
/// content by that much again. A downloaded **file** has no such parameter and
/// begins at the episode's own zero, so it needs the opposite — no base, and a
/// real seek. Treating a file like a stream is why backgrounding a downloaded
/// episode restarted it from 0:00 while the lockscreen showed the right time.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) fn background_audio_plan(
is_local_file: bool,
position_seconds: f64,
) -> BackgroundAudioPlan {
let position = position_seconds.max(0.0);
if is_local_file {
BackgroundAudioPlan {
base_seconds: 0.0,
seek_to: (position > 0.0).then_some(position),
}
} else {
BackgroundAudioPlan {
base_seconds: position,
seek_to: None,
}
}
}
/// Resolve the on-disk file backing a completed download, if there is one. /// Resolve the on-disk file backing a completed download, if there is one.
/// ///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual /// A `downloads` row is not proof of a file: it can outlive the bytes (manual
@@ -687,6 +753,9 @@ pub async fn player_enter_background_audio(
item.id item.id
); );
} }
// A downloaded file starts at the episode's zero; a stream starts at the
// handoff point. Only one of them has a base, and only the other needs a seek.
let plan = background_audio_plan(local_path.is_some(), position_seconds);
let source = background_audio_source(local_path, item.stream_url, &item.id); let source = background_audio_source(local_path, item.stream_url, &item.id);
// Build an AUDIO media item pointing at the audio-only stream. We do not use // Build an AUDIO media item pointing at the audio-only stream. We do not use
@@ -730,21 +799,28 @@ pub async fn player_enter_background_audio(
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is // relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber. // absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0)); let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
let controller = player.0.lock().await; let controller = player.0.lock().await;
// Remember where the video was: the audio stream's zero == this position // Remember where the video was: for a stream the audio's zero == this
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add // position (the URL was built with StartTimeTicks=position_seconds), so on
// this base to the native player's relative position to get the absolute one. // exit we add this base to the native player's relative position to get the
// The controller owns it so a backend-driven advance to the next episode // absolute one. The controller owns it so a backend-driven advance to the
// clears it along with the stream it described. // next episode clears it along with the stream it described.
controller.enter_background_audio(position_seconds); controller.enter_background_audio(plan.base_seconds);
controller controller
.play_item(media_item) .play_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// NOTE: do NOT seek here. The audio-only URL already starts at the handoff // Seek ONLY a local file. The audio-only URL already starts at the handoff
// position via StartTimeTicks; the stream's timeline begins at 0 == that // position via StartTimeTicks — its timeline begins at 0 == that point — so
// point, so an extra seek(position_seconds) would jump PAST the content. // seeking a stream would jump PAST the content by the handoff position again.
if let Some(seek_to) = plan.seek_to {
info!(
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
seek_to
);
controller.seek(seek_to).map_err(|e| e.to_string())?;
}
controller.emit_queue_changed(); controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() { if let Some(emitter) = controller.event_emitter() {
@@ -770,22 +846,30 @@ pub async fn player_enter_background_audio(
pub async fn player_exit_background_audio( pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> { ) -> Result<f64, String> {
// Back to foreground playback: the lockscreen scrubber is absolute again.
let _ = crate::player::set_lockscreen_position_offset(0.0);
let controller = player.0.lock().await; let controller = player.0.lock().await;
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Zero after a backend-driven // Read the position BEFORE clearing either base. The position tick applies the
// episode advance, whose stream already starts at its own zero. // base natively, so a tick landing between "base cleared" and "position read"
let base = controller.exit_background_audio(); // would hand back a relative position — the whole bug, reintroduced at the one
// Capture position into a `let` BEFORE stop() — never hold work across a lock // moment it matters most. Capturing into a `let` before stop() is also the
// re-entrant call (deadlock discipline, CLAUDE.md). // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
let relative = controller.position(); // (DR-159)
//
// `absolute_position` rather than `position`, because a tick that has not
// landed *yet* is the same hazard from the other side: returning to the
// foreground while the audio-only transcode is still opening read 0.0, and
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
// beginning. Flooring at the handoff base cannot overshoot: the stream is
// physically incapable of being behind its own starting point. (DR-178)
let absolute = controller.absolute_position();
// Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0);
controller.exit_background_audio();
controller.stop().map_err(|e| e.to_string())?; controller.stop().map_err(|e| e.to_string())?;
let absolute = base + relative;
info!( info!(
"player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s", "player_exit_background_audio: resuming the video at {:.1}s",
base, relative, absolute absolute
); );
Ok(absolute) Ok(absolute)
} }
@@ -1003,6 +1087,16 @@ pub async fn player_stop(
.clone() .clone()
}; };
client.send_session_command(session_id, "Stop").await?; client.send_session_command(session_id, "Stop").await?;
// Stopping the remote session ends the cast, so the manager returns to
// Idle — same as a local stop. This is also what hands OS volume control
// back to this device: set_mode releases the Android remote volume
// provider on any exit from remote mode. Without it the mode stayed
// Remote and the system volume slider remained stuck on the remote
// session with no way back to the local speaker.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
} else { } else {
// Local playback // Local playback
let controller = player.0.lock().await; let controller = player.0.lock().await;
@@ -1197,9 +1291,12 @@ pub async fn player_seek(
let position_ticks = (position * 10_000_000.0) as i64; let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?; client.session_seek(session_id, position_ticks).await?;
} else { } else {
// Local playback // Local playback. seek_absolute, not seek: the position came from the UI,
// which shows the whole item, so during a background-audio handoff it has
// to be resolved against the episode's timeline rather than the handoff
// stream's. (DR-159)
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.seek(position).map_err(|e| e.to_string())?; controller.seek_absolute(position).await?;
} }
let controller = player.0.lock().await; let controller = player.0.lock().await;
@@ -1295,7 +1392,6 @@ pub async fn player_seek_video(
.get_video_stream_url( .get_video_stream_url(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
Some(position),
audio_stream_index, audio_stream_index,
) )
.await .await
@@ -1306,6 +1402,13 @@ pub async fn player_seek_video(
position position
); );
// `seek_offset` carries the position to RESUME AT, not a base to add
// to the element's clock. The reloaded stream starts at the item's
// zero — a position on an HLS playlist makes the server 400 every
// segment behind it (DR-181) — so the adapter reaches the position by
// seeking the element and leaves the transcode offset at zero. The
// field keeps its name only because renaming it means regenerating
// the specta bindings; `reloadSource` documents the contract.
Ok(VideoSeekResponse::ReloadStream { Ok(VideoSeekResponse::ReloadStream {
new_url, new_url,
seek_offset: position, seek_offset: position,
@@ -1319,7 +1422,6 @@ pub async fn player_seek_video(
.get_video_stream_url( .get_video_stream_url(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
Some(position),
audio_stream_index, audio_stream_index,
) )
.await .await
@@ -1357,6 +1459,11 @@ pub async fn player_seek_video(
} else { } else {
return Err("No current item after URL update".to_string()); return Err("No current item after URL update".to_string());
} }
// The re-opened stream begins at zero — the position cannot ride
// along in the URL without 400ing every segment (DR-181) — so the
// seek that the reload was asked for happens here.
controller.seek(position).map_err(|e| e.to_string())?;
} }
info!( info!(
@@ -1371,8 +1478,22 @@ pub async fn player_seek_video(
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch) /// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
/// Note: Frontend should handle saving series preferences after this command succeeds /// Note: Frontend should handle saving series preferences after this command succeeds
///
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
/// change audio track, so the stream is re-opened at the chosen
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
/// `position`; a native backend (ExoPlayer) switches in place by track-group
/// index. libmpv implements neither — it is the audio-only backend here and
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
/// which is why IR-019 is met by these two paths rather than by MPV.
///
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_switch_audio_track( pub async fn player_switch_audio_track(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
@@ -1407,12 +1528,13 @@ pub async fn player_switch_audio_track(
.to_string() .to_string()
}; };
// Get new stream URL with selected audio track // Get new stream URL with selected audio track. It starts at zero — an
// HLS playlist cannot carry a position (DR-181) — and `position` below
// tells the frontend where to seek the reloaded element back to.
let new_url = repository let new_url = repository
.get_video_stream_url( .get_video_stream_url(
&jellyfin_item_id, &jellyfin_item_id,
media_source_id.as_deref(), media_source_id.as_deref(),
current_position,
Some(stream_index), Some(stream_index),
) )
.await .await
@@ -1433,6 +1555,122 @@ pub async fn player_switch_audio_track(
} }
} }
/// Change the bandwidth ceiling of the video that is playing *right now*.
///
/// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
///
/// The change applies to this playback *and* to everything started afterwards
/// (it sets the process-wide ceiling), but it is deliberately **not** persisted:
/// the in-player picker is a "this film, this connection" control, and the
/// durable default belongs to Settings. `player_set_video_settings` is the one
/// that writes to the database.
///
/// TRACES: UR-074 | DR-162
#[tauri::command]
#[specta::specta]
// Three of the nine arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_set_stream_quality(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
video_settings: State<'_, VideoSettingsWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
quality.label(),
use_html5,
current_position
);
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
if current_item.media_type != MediaType::Video {
return Err("Current item is not a video".to_string());
}
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
// Set the ceiling *before* building the URL — the builder reads it.
crate::repository::online::set_streaming_quality(quality);
{
let mut settings = video_settings.0.lock().map_err(|e| e.to_string())?;
settings.streaming_quality = quality;
}
let position = current_position.unwrap_or(0.0);
let new_url = repository
.get_video_stream_url(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream { new_url, position });
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
// where the picture was.
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
let queue_arc = controller.queue();
{
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url.clone()) {
return Err("Failed to update stream URL in queue".to_string());
}
}
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let updated_item = queue.current().ok_or("No current item after URL update")?;
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
if position > 0.0 {
controller.seek(position).map_err(|e| e.to_string())?;
}
}
Ok(StreamQualityResponse::Native { position })
}
/// Set the active audio track on a native backend directly.
///
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_set_audio_track( pub async fn player_set_audio_track(
@@ -1446,6 +1684,14 @@ pub async fn player_set_audio_track(
Ok(get_player_status(&controller)) Ok(get_player_status(&controller))
} }
/// Set (or clear, with `None`) the active subtitle track on a native backend.
///
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
/// children. libmpv implements neither, leaving the trait default in place.
///
/// TRACES: UR-020 | IR-018, DR-023
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_set_subtitle_track( pub async fn player_set_subtitle_track(
@@ -1567,7 +1813,7 @@ pub async fn player_get_status(
let local_media = { let local_media = {
let queue_arc = controller.queue(); let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?; let queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.current().map(|item| MergedMediaItem::from(item)) queue.current().map(MergedMediaItem::from)
}; };
let local_is_playing = status.state.is_playing(); let local_is_playing = status.state.is_playing();
@@ -1591,10 +1837,7 @@ pub async fn player_get_status(
log::info!("[PlayerCommands] Merging remote session state"); log::info!("[PlayerCommands] Merging remote session state");
// Merge media item // Merge media item
status.merged_media = session status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
.now_playing_item
.as_ref()
.map(|item| MergedMediaItem::from(item));
// Merge isPlaying (NOT isPaused!) // Merge isPlaying (NOT isPaused!)
status.merged_is_playing = session status.merged_is_playing = session
@@ -1703,7 +1946,11 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
PlayerStatus { PlayerStatus {
state: controller.state(), state: controller.state(),
position: controller.position(), // The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(), duration: controller.duration(),
volume: controller.volume(), volume: controller.volume(),
muted: controller.muted(), muted: controller.muted(),
@@ -2520,6 +2767,8 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::utils::lock::MutexSafe;
/// The subtitle list the frontend resolved must survive the IPC hop and end /// The subtitle list the frontend resolved must survive the IPC hop and end
/// up on the `MediaItem` the native backend loads. /// up on the `MediaItem` the native backend loads.
/// ///
@@ -2695,6 +2944,49 @@ mod tests {
} }
} }
/// The two sources start in different places, so the handoff cannot treat
/// them alike.
///
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
/// the handoff point that stream's zero: the base is the handoff position and
/// seeking would jump past the content. A *downloaded file* has no such
/// parameter — it starts at the episode's own zero — so basing it at the
/// handoff position claims 18 minutes of audio that is about to play from the
/// beginning. That is the downloaded-episode version of "it restarts when the
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
use super::background_audio_plan;
let local = background_audio_plan(true, 1104.0);
assert_eq!(local.base_seconds, 0.0);
assert_eq!(local.seek_to, Some(1104.0));
let streamed = background_audio_plan(false, 1104.0);
assert_eq!(streamed.base_seconds, 1104.0);
assert_eq!(
streamed.seek_to, None,
"the URL already starts at the handoff point; seeking again skips past it"
);
}
/// Handing off at the very start has nothing to seek to and nothing to base:
/// both sources are already where they need to be.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
use super::background_audio_plan;
for local in [true, false] {
let plan = background_audio_plan(local, 0.0);
assert_eq!(plan.base_seconds, 0.0);
assert_eq!(plan.seek_to, None);
}
}
/// A downloaded item must resolve to its file, and a `downloads` row whose /// A downloaded item must resolve to its file, and a `downloads` row whose
/// file has gone must resolve to `None` so the caller falls back to /// file has gone must resolve to `None` so the caller falls back to
/// streaming instead of handing the player a path that cannot be opened. /// streaming instead of handing the player a path that cannot be opened.
@@ -2799,7 +3091,7 @@ mod tests {
let database = Database::open_in_memory().unwrap(); let database = Database::open_in_memory().unwrap();
{ {
let conn = database.connection(); let conn = database.connection();
let conn = conn.lock().unwrap(); let conn = conn.lock_safe();
conn.execute_batch(&format!( conn.execute_batch(&format!(
r#" r#"
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test'); INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
@@ -2855,7 +3147,7 @@ mod tests {
assert_eq!(switched, 1, "only the download whose file exists switches"); assert_eq!(switched, 1, "only the download whose file exists switches");
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock().unwrap(); let queue_lock = queue.lock_safe();
match &queue_lock.items()[0].source { match &queue_lock.items()[0].source {
MediaSource::Local { MediaSource::Local {
file_path, file_path,
@@ -2884,7 +3176,7 @@ mod tests {
index_number: Option<i32>, index_number: Option<i32>,
} }
let mut tracks = vec![ let mut tracks = [
MockTrack { MockTrack {
id: "track1".to_string(), id: "track1".to_string(),
name: "Song 1".to_string(), name: "Song 1".to_string(),
@@ -2977,7 +3269,7 @@ mod tests {
} }
// Create tracks in random order (not sorted) // Create tracks in random order (not sorted)
let mut tracks = vec![ let mut tracks = [
MockTrack { MockTrack {
id: "id5".to_string(), id: "id5".to_string(),
name: "Track 5".to_string(), name: "Track 5".to_string(),
+131 -3
View File
@@ -1,12 +1,29 @@
//! Audio and video playback settings commands. //! Audio and video playback settings commands.
//! //!
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020 //! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033, UR-074 | DR-025, DR-030, DR-034, DR-035, DR-036, DR-162, IR-020
use tauri::State; use std::sync::Arc;
use log::{info, warn};
use tauri::{Manager, State};
use super::{PlayerStateWrapper, VideoSettingsWrapper}; use super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::commands::storage::DatabaseWrapper;
use crate::player::AutoplaySettings; use crate::player::AutoplaySettings;
use crate::settings::{AudioSettings, EqPreset, VideoSettings}; use crate::settings::{AudioSettings, EqPreset, StreamingQuality, VideoSettings};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::utils::lock::MutexSafe;
/// `app_settings` key holding the persisted streaming bandwidth ceiling.
///
/// The cap is persisted (unlike the rest of `VideoSettings`, which is
/// process-lifetime state) because forgetting it is the one failure that costs
/// the user something real: a limit set for a metered connection that silently
/// reverts to uncapped on the next launch spends their data allowance without
/// ever showing them a changed setting.
///
/// TRACES: UR-074 | DR-162
const STREAMING_QUALITY_KEY: &str = "streaming_quality";
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -54,6 +71,7 @@ pub async fn player_get_audio_settings(
pub async fn player_set_video_settings( pub async fn player_set_video_settings(
video_settings: State<'_, VideoSettingsWrapper>, video_settings: State<'_, VideoSettingsWrapper>,
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
settings: VideoSettings, settings: VideoSettings,
) -> Result<VideoSettings, String> { ) -> Result<VideoSettings, String> {
let validated = settings.with_countdown_clamped(); let validated = settings.with_countdown_clamped();
@@ -62,6 +80,12 @@ pub async fn player_set_video_settings(
*current = validated.clone(); *current = validated.clone();
} // Drop MutexGuard before await } // Drop MutexGuard before await
// The bandwidth ceiling is read by the repository's URL builders and by the
// PlaybackInfo negotiation, neither of which can see this wrapper.
// TRACES: UR-074 | DR-162
crate::repository::online::set_streaming_quality(validated.streaming_quality);
persist_streaming_quality(&db, validated.streaming_quality).await;
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values // Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.set_autoplay_settings(AutoplaySettings { controller.set_autoplay_settings(AutoplaySettings {
@@ -73,6 +97,110 @@ pub async fn player_set_video_settings(
Ok(validated) Ok(validated)
} }
/// The bandwidth ceilings the quality picker may offer, each with the label and
/// one-line detail to show for it, highest first.
///
/// The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
/// frontend reads them here rather than encoding them — the same arrangement as
/// [`player_get_eq_presets`].
///
/// TRACES: UR-074 | DR-162
#[tauri::command]
#[specta::specta]
pub async fn player_get_streaming_qualities(
) -> Result<Vec<(StreamingQuality, String, String)>, String> {
Ok(StreamingQuality::ALL
.iter()
.map(|q| (*q, q.label().to_string(), q.detail().to_string()))
.collect())
}
/// Write the ceiling to `app_settings`. Failure is logged, not returned: the
/// setting has already been applied in memory, and refusing the whole call
/// because the write failed would leave the UI showing a cap that *is* active.
///
/// TRACES: UR-074 | DR-162
async fn persist_streaming_quality(db: &State<'_, DatabaseWrapper>, quality: StreamingQuality) {
let db_service = {
let database = db.0.lock_safe();
Arc::new(database.service())
};
let encoded = match serde_json::to_string(&quality) {
Ok(value) => value,
Err(e) => {
warn!("[VideoSettings] Failed to encode streaming quality: {}", e);
return;
}
};
let query = Query::with_params(
"INSERT OR REPLACE INTO app_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(STREAMING_QUALITY_KEY.to_string()),
QueryParam::String(encoded),
],
);
if let Err(e) = db_service.execute(query).await {
warn!("[VideoSettings] Failed to persist streaming quality: {}", e);
}
}
/// Restore the persisted bandwidth ceiling at startup, into both the repository
/// (which enforces it) and `VideoSettings` (which the settings UI reads).
///
/// Called from the Tauri `setup` hook. A missing or unreadable row leaves the
/// default — uncapped — in place, so a database problem degrades to the old
/// behaviour rather than to an arbitrary limit.
///
/// TRACES: UR-074 | DR-162
pub async fn restore_streaming_quality(app: &tauri::AppHandle) {
let db_service = {
let Some(db) = app.try_state::<DatabaseWrapper>() else {
warn!("[VideoSettings] No database available; streaming quality stays uncapped");
return;
};
let database = db.0.lock_safe();
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(STREAMING_QUALITY_KEY.to_string())],
);
let stored: Option<String> = match db_service.query_optional(query, |row| row.get(0)).await {
Ok(value) => value,
Err(e) => {
warn!("[VideoSettings] Failed to read streaming quality: {}", e);
return;
}
};
let Some(stored) = stored else { return };
let quality: StreamingQuality = match serde_json::from_str(&stored) {
Ok(quality) => quality,
Err(e) => {
warn!(
"[VideoSettings] Ignoring unrecognised persisted streaming quality {:?}: {}",
stored, e
);
return;
}
};
crate::repository::online::set_streaming_quality(quality);
if let Some(video_settings) = app.try_state::<VideoSettingsWrapper>() {
video_settings.0.lock_safe().streaming_quality = quality;
}
info!(
"[VideoSettings] Restored streaming quality cap: {}",
quality.label()
);
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn player_get_video_settings( pub async fn player_get_video_settings(
+75 -18
View File
@@ -1,7 +1,7 @@
//! Tauri commands for repository access //! Tauri commands for repository access
//! Uses handle-based system: UUID -> Arc<HybridRepository> //! Uses handle-based system: UUID -> Arc<HybridRepository>
//! //!
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031 //! TRACES: UR-007, UR-008, UR-023, UR-034, UR-035, UR-036 | IR-022, IR-024, JA-004, JA-005, JA-006, JA-029, JA-030, JA-031
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
use std::collections::HashMap; use std::collections::HashMap;
@@ -67,6 +67,10 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Returns a handle (UUID) for accessing the repository /// Returns a handle (UUID) for accessing the repository
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
// Four of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the remaining four into a struct would change the IPC contract
// and the generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn repository_create( pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
player: State<'_, crate::commands::player::PlayerStateWrapper>, player: State<'_, crate::commands::player::PlayerStateWrapper>,
@@ -294,7 +298,13 @@ pub async fn repository_get_latest_items(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get resume items (continue watching/listening) /// Get resume items (continue watching/listening).
///
/// The home screen's Continue Watching row and every library's "pick up where
/// you left off" hero come through here; each item carries its own resume
/// position in `UserData`.
///
/// TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn repository_get_resume_items( pub async fn repository_get_resume_items(
@@ -318,7 +328,9 @@ pub async fn repository_get_resume_items(
}) })
} }
/// Get next up episodes /// Get next up episodes.
///
/// TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn repository_get_next_up_episodes( pub async fn repository_get_next_up_episodes(
@@ -571,7 +583,13 @@ pub async fn repository_get_playback_info(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get video stream URL with optional seeking support /// Get a video stream URL.
///
/// There is no start-position parameter on purpose: the URL is an HLS playlist
/// covering the whole item, and a position on it makes the server reject every
/// segment with `400` (DR-181). Callers resume by seeking after load.
///
/// TRACES: UR-004 | DR-181 | UT-182
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn repository_get_video_stream_url( pub async fn repository_get_video_stream_url(
@@ -579,17 +597,11 @@ pub async fn repository_get_video_stream_url(
handle: String, handle: String,
item_id: String, item_id: String,
media_source_id: Option<String>, media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>, audio_stream_index: Option<i32>,
) -> Result<String, String> { ) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref() repo.as_ref()
.get_video_stream_url( .get_video_stream_url(&item_id, media_source_id.as_deref(), audio_stream_index)
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await .await
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
@@ -712,9 +724,19 @@ pub async fn repository_report_playback_progress(
} }
/// Report playback stopped /// Report playback stopped
///
/// A stop-report that cannot reach the server is queued rather than dropped:
/// this is the position the resume point is built from, and losing it is
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
/// failing the command because the *queue* write failed would tell the caller
/// the report was lost when the local position was already saved.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn repository_report_playback_stopped( pub async fn repository_report_playback_stopped(
db: State<'_, crate::commands::storage::DatabaseWrapper>,
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
@@ -723,10 +745,39 @@ pub async fn repository_report_playback_stopped(
// Milliseconds across the boundary; the Jellyfin API wants ticks. // Milliseconds across the boundary; the Jellyfin API wants ticks.
let position_ticks = position_ms * 10_000; let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
let result = repo
.as_ref()
.report_playback_stopped(&item_id, position_ticks) .report_playback_stopped(&item_id, position_ticks)
.await;
if let Err(e) = &result {
let db_service = {
let database = db.0.lock().map_err(|err| err.to_string())?;
Arc::new(database.service())
};
let user_id = repo.user_id().to_string();
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
&db_service,
&user_id,
&item_id,
position_ticks,
)
.await .await
.map_err(|e| format!("{:?}", e)) {
warn!(
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
item_id, e, queue_err
);
} else {
debug!(
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
item_id, e
);
}
}
result.map_err(|e| format!("{:?}", e))
} }
/// Get image URL for an item /// Get image URL for an item
@@ -765,7 +816,7 @@ pub fn repository_get_subtitle_url(
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
#[allow(dead_code)] #[allow(dead_code)]
pub fn repository_get_video_download_url( pub async fn repository_get_video_download_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
item_id: String, item_id: String,
@@ -773,9 +824,16 @@ pub fn repository_get_video_download_url(
media_source_id: Option<String>, media_source_id: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo // Async because the audio-codec policy has to know what the source's audio
.as_ref() // is before it can decide whether the file may be copied verbatim (DR-171).
.get_video_download_url(&item_id, &quality, media_source_id.as_deref())) // The frontend calls this exactly as before — the decision stays in Rust.
Ok(crate::repository::resolve_video_download_url(
repo.as_ref(),
&item_id,
&quality,
media_source_id.as_deref(),
)
.await)
} }
/// Mark an item as favorite /// Mark an item as favorite
@@ -1045,7 +1103,6 @@ mod tests {
let handle = format!("{}", uuid); let handle = format!("{}", uuid);
// UUID should convert to a non-empty string // UUID should convert to a non-empty string
assert!(!handle.is_empty()); assert!(!handle.is_empty());
assert!(handle.len() > 0);
} }
#[test] #[test]
+1 -1
View File
@@ -89,7 +89,7 @@ mod tests {
#[test] #[test]
fn test_session_poller_wrapper_structure() { fn test_session_poller_wrapper_structure() {
// Test that wrapper type structure is correct // Test that wrapper type structure is correct
assert_eq!(std::mem::size_of::<SessionPollerWrapper>() > 0, true); assert!(std::mem::size_of::<SessionPollerWrapper>() > 0);
} }
#[test] #[test]
+83 -3
View File
@@ -867,6 +867,86 @@ pub async fn storage_mark_played(
} }
} }
/// Set the watched flag locally for an item **and everything inside it**.
///
/// This backs the watched toggle, and is deliberately separate from
/// [`storage_mark_played`] — which reports a single track/episode finishing and
/// increments `play_count` — because the toggle has two directions and applies
/// to containers.
///
/// The recursion is what makes the toggle honest offline. Jellyfin applies
/// `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
/// online the server fixes up the children on the next read; with no server to
/// ask, marking a season watched would otherwise tick the season and leave every
/// episode inside it unwatched. Targets are drawn from `items` by the same link
/// columns the rest of the offline layer uses, so an id that is not cached
/// selects nothing and the statement is a no-op rather than a foreign-key error.
///
/// Un-marking clears the resume position too, matching the server, so an item
/// un-marked offline does not come back offering to resume from a position it is
/// no longer meant to have.
///
/// `pending_sync = 1` hands the rows to the sync drain.
///
/// TRACES: UR-073 | DR-158
#[tauri::command]
#[specta::specta]
pub async fn storage_set_watched(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
watched: bool,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// The item itself plus its descendants: a season's episodes reach it by
// season_id, a series' by series_id, its seasons by parent_id, an album's
// tracks by album_id.
let targets = "SELECT id FROM items
WHERE id = ? OR parent_id = ? OR album_id = ?
OR season_id = ? OR series_id = ?";
let sql = if watched {
format!(
"INSERT INTO user_data (user_id, item_id, is_played, play_count, last_played_at, pending_sync)
SELECT ?, id, 1, 1, CURRENT_TIMESTAMP, 1 FROM ({targets})
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_played = 1,
play_count = MAX(user_data.play_count, 1),
last_played_at = CURRENT_TIMESTAMP,
pending_sync = 1"
)
} else {
format!(
"INSERT INTO user_data (user_id, item_id, is_played, play_count, playback_position_ticks, pending_sync)
SELECT ?, id, 0, 0, 0, 1 FROM ({targets})
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_played = 0,
play_count = 0,
playback_position_ticks = 0,
pending_sync = 1"
)
};
let query = Query::with_params(
sql,
vec![
QueryParam::String(user_id),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get playback progress for an item /// Get playback progress for an item
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@@ -1578,19 +1658,19 @@ mod tests {
#[test] #[test]
fn test_database_wrapper_structure() { fn test_database_wrapper_structure() {
// Verify DatabaseWrapper can be created and holds Mutex<Database> // Verify DatabaseWrapper can be created and holds Mutex<Database>
assert_eq!(std::mem::size_of::<DatabaseWrapper>() > 0, true); assert!(std::mem::size_of::<DatabaseWrapper>() > 0);
} }
#[test] #[test]
fn test_credential_store_wrapper_structure() { fn test_credential_store_wrapper_structure() {
// Verify CredentialStoreWrapper can be created // Verify CredentialStoreWrapper can be created
assert_eq!(std::mem::size_of::<CredentialStoreWrapper>() > 0, true); assert!(std::mem::size_of::<CredentialStoreWrapper>() > 0);
} }
#[test] #[test]
fn test_thumbnail_cache_wrapper_structure() { fn test_thumbnail_cache_wrapper_structure() {
// Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache> // Verify ThumbnailCacheWrapper holds Arc<ThumbnailCache>
assert_eq!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0, true); assert!(std::mem::size_of::<ThumbnailCacheWrapper>() > 0);
} }
#[test] #[test]
+258 -2
View File
@@ -52,6 +52,12 @@ pub enum QueuedOp {
MarkPlayed { MarkPlayed {
item_id: String, item_id: String,
}, },
/// The inverse, queued by the watched toggle. Pushes as `clear_watch_history`
/// (Jellyfin's mark-unplayed), which also zeroes the resume position — so an
/// item un-marked offline does not come back carrying a stale position.
MarkUnplayed {
item_id: String,
},
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync` /// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
/// (DR-120). Supported so a row written by an older build still lands. /// (DR-120). Supported so a row written by an older build still lands.
Favorite { Favorite {
@@ -105,6 +111,7 @@ pub fn parse_queued_op(
position_ticks: ticks(), position_ticks: ticks(),
}), }),
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }), "mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
"mark_unplayed" => Ok(QueuedOp::MarkUnplayed { item_id }),
"mark_favorite" => Ok(QueuedOp::Favorite { "mark_favorite" => Ok(QueuedOp::Favorite {
item_id, item_id,
is_favorite: true, is_favorite: true,
@@ -137,6 +144,7 @@ impl<T: MediaRepository + ?Sized> SyncSink for T {
position_ticks, position_ticks,
} => self.report_playback_stopped(item_id, *position_ticks).await, } => self.report_playback_stopped(item_id, *position_ticks).await,
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await, QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
QueuedOp::MarkUnplayed { item_id } => self.clear_watch_history(item_id).await,
QueuedOp::Favorite { QueuedOp::Favorite {
item_id, item_id,
is_favorite, is_favorite,
@@ -355,6 +363,65 @@ async fn mark_failed(
Ok(()) Ok(())
} }
/// Queue a watch position that could not be reported to the server.
///
/// The stop-report path pushed straight to the server and, on failure, logged
/// and dropped the position — so closing a video while the server was
/// unreachable lost the resume point outright, even though the queue and its
/// drain (DR-131) were built and running. This is the missing producer.
///
/// The pending row for an item is *replaced* rather than appended to. Progress
/// is reported every 10s, so a server that stays down would otherwise grow one
/// row per tick, all of them superseded by the newest — the unbounded queue
/// DR-131 exists to prevent. Only `pending`/`failed` rows are superseded:
/// an `abandoned` row has been given up on and must not be revived, and a
/// `completed` one is history.
///
/// TRACES: UR-025 | DR-154 | UT-151
pub async fn enqueue_playback_stopped(
db: &Arc<RusqliteService>,
user_id: &str,
item_id: &str,
position_ticks: i64,
) -> Result<(), String> {
let payload = format!(r#"{{"position_ticks": {}}}"#, position_ticks);
// Supersede an already-queued position for this item, keeping its place in
// the queue order (created_at) so a later item cannot overtake it.
let updated = db
.execute(Query::with_params(
"UPDATE sync_queue \
SET payload = ?, status = 'pending', error_message = NULL \
WHERE user_id = ? AND item_id = ? AND operation = 'report_playback_stopped' \
AND status IN ('pending', 'failed')",
vec![
QueryParam::String(payload.clone()),
QueryParam::String(user_id.to_string()),
QueryParam::String(item_id.to_string()),
],
))
.await?;
if updated == 0 {
db.execute(Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at) \
VALUES (?, 'report_playback_stopped', ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id.to_string()),
QueryParam::String(item_id.to_string()),
QueryParam::String(payload),
],
))
.await?;
}
debug!(
"[SyncQueue] Queued unreported stop for {} at {} ticks",
item_id, position_ticks
);
Ok(())
}
/// Drain on every offline→online transition. /// Drain on every offline→online transition.
/// ///
/// TRACES: UR-025 | DR-131 /// TRACES: UR-025 | DR-131
@@ -414,6 +481,7 @@ pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::utils::lock::MutexSafe;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::Mutex; use std::sync::Mutex;
@@ -450,7 +518,7 @@ mod tests {
} }
fn calls(&self) -> Vec<QueuedOp> { fn calls(&self) -> Vec<QueuedOp> {
self.calls.lock().unwrap().clone() self.calls.lock_safe().clone()
} }
} }
@@ -460,7 +528,7 @@ mod tests {
if let Some(err) = &self.fail_with { if let Some(err) = &self.fail_with {
return Err(err.clone()); return Err(err.clone());
} }
self.calls.lock().unwrap().push(op.clone()); self.calls.lock_safe().push(op.clone());
Ok(()) Ok(())
} }
} }
@@ -781,6 +849,143 @@ mod tests {
assert_eq!(row_state(&db, "theirs").await.0, "pending"); assert_eq!(row_state(&db, "theirs").await.0, "pending");
} }
/// The bug DR-154 fixes: a stop-report that could not reach the server was
/// logged and dropped, so the watch position was lost outright. It must
/// land in the queue the drain already knows how to push.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_failed_stop_report_is_queued_rather_than_dropped() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 5_000_000_000)
.await
.unwrap();
// The very drain that already exists must be able to push it.
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 5_000_000_000,
}]
);
assert_eq!(report.pushed, 1);
assert_eq!(report.remaining, 0);
}
/// Progress is reported every 10s, and a server that stays unreachable
/// would otherwise add a row per tick — an unbounded queue of positions
/// that are all superseded by the newest one. The pending row for an item
/// is replaced in place, so the queue holds the latest position only.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_the_same_item_supersedes_the_earlier_position() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep1", 2_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep1", 3_000)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
// One row, carrying the newest position — not three.
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 3_000,
}]
);
}
/// Distinct items must not collide — superseding is per item, not global.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_keeps_positions_for_different_items_apart() {
let db = test_db();
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
.await
.unwrap();
enqueue_playback_stopped(&db, "u1", "ep2", 2_000)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
let mut calls = sink.calls();
calls.sort_by_key(|op| match op {
QueuedOp::PlaybackStopped { item_id, .. } => item_id.clone(),
_ => String::new(),
});
assert_eq!(
calls,
vec![
QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 1_000,
},
QueuedOp::PlaybackStopped {
item_id: "ep2".to_string(),
position_ticks: 2_000,
},
]
);
}
/// A row already abandoned (DR-131 gave up on it) must not be resurrected
/// by a later report — that would restore the queue-that-only-grows this
/// whole area exists to prevent. The new report is queued as its own row.
///
/// TRACES: UR-025 | DR-154 | UT-151
#[tokio::test]
async fn test_requeueing_does_not_revive_an_abandoned_row() {
let db = test_db();
seed(
&db,
&[(
"u1",
"report_playback_stopped",
"ep1",
Some(r#"{"position_ticks": 111}"#),
"abandoned",
MAX_SYNC_ATTEMPTS,
"2026-08-01T10:00:00Z",
)],
)
.await;
enqueue_playback_stopped(&db, "u1", "ep1", 999)
.await
.unwrap();
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
// Only the fresh row is pushed; the abandoned one stays abandoned.
assert_eq!(
sink.calls(),
vec![QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 999,
}]
);
}
/// Nothing queued means no server calls at all — a reconnect must not /// Nothing queued means no server calls at all — a reconnect must not
/// generate traffic just because it happened. /// generate traffic just because it happened.
/// ///
@@ -835,4 +1040,55 @@ mod tests {
assert!(parse_queued_op("mark_played", None, None).is_err()); assert!(parse_queued_op("mark_played", None, None).is_err());
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err()); assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
} }
/// Un-marking watched queues like marking watched does, so the toggle works
/// in both directions while the server is unreachable rather than only one.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[test]
fn test_parse_accepts_mark_unplayed() {
assert_eq!(
parse_queued_op("mark_unplayed", Some("ep1"), None).unwrap(),
QueuedOp::MarkUnplayed {
item_id: "ep1".to_string()
},
);
assert!(parse_queued_op("mark_unplayed", None, None).is_err());
}
/// The queued un-mark reaches the server as `clear_watch_history` — Jellyfin's
/// mark-unplayed, which also zeroes the resume position, so a series returns
/// to "never watched" rather than keeping a stale position.
///
/// TRACES: UR-073 | DR-158 | UT-154
#[tokio::test]
async fn test_drain_pushes_mark_unplayed() {
let db = test_db();
seed(
&db,
&[(
"u1",
"mark_unplayed",
"ep9",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
)],
)
.await;
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::MarkUnplayed {
item_id: "ep9".to_string()
}],
);
assert_eq!(report.pushed, 1);
assert_eq!(report.remaining, 0);
}
} }
+107 -10
View File
@@ -20,9 +20,6 @@ use sha2::{Digest, Sha256};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
#[cfg(target_os = "linux")]
use hostname;
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const SERVICE_NAME: &str = "com.dtourolle.jellytau"; const SERVICE_NAME: &str = "com.dtourolle.jellytau";
@@ -203,15 +200,12 @@ impl CredentialStore {
// secret-tool doesn't support --version, so we test with a search command // secret-tool doesn't support --version, so we test with a search command
// that will succeed even if no items are found // that will succeed even if no items are found
match Command::new("secret-tool") Command::new("secret-tool")
.arg("search") .arg("search")
.arg("service") .arg("service")
.arg("__nonexistent_test__") .arg("__nonexistent_test__")
.output() .output()
{ .is_ok()
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
} }
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))] #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
@@ -471,6 +465,19 @@ impl CredentialStore {
hasher.finalize().into() hasher.finalize().into()
} }
/// Load and decrypt the credential map.
///
/// A file that is present but **undecryptable** is deliberately reported as
/// an *empty* credential set rather than as an error. The key never leaves
/// the device it was derived on (Android Keystore keys are never backed up,
/// and the file fallback's key is derived from machine identifiers), so a
/// restored/transferred install gets ciphertext with no key and every read
/// would fail *permanently*. Surfacing that as an error made session restore
/// throw instead of falling back to the login screen: an unrecoverable app
/// rather than a clean logged-out one. The next successful login re-encrypts
/// the file with the current key, so the state self-heals.
///
/// TRACES: UR-012 | IR-014
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> { fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
if !self.credentials_path.exists() { if !self.credentials_path.exists() {
return Ok(serde_json::json!({})); return Ok(serde_json::json!({}));
@@ -483,8 +490,31 @@ impl CredentialStore {
return Ok(serde_json::json!({})); return Ok(serde_json::json!({}));
} }
let decrypted = self.decrypt(&encrypted_data)?; let decrypted = match self.decrypt(&encrypted_data) {
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string())) Ok(decrypted) => decrypted,
Err(e) => {
warn!(
"Credentials file at {:?} exists but cannot be decrypted ({}); \
treating as no stored credentials. This is expected after a \
backup restore or device transfer - the encryption key does \
not travel with the data. Signing in again will rewrite it.",
self.credentials_path, e
);
return Ok(serde_json::json!({}));
}
};
match serde_json::from_str(&decrypted) {
Ok(value) => Ok(value),
Err(e) => {
warn!(
"Credentials file at {:?} decrypted to invalid JSON ({}); \
treating as no stored credentials.",
self.credentials_path, e
);
Ok(serde_json::json!({}))
}
}
} }
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> { fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
@@ -856,6 +886,73 @@ pub use android_keystore::{
mod tests { mod tests {
use super::*; use super::*;
/// Build a store pinned to the encrypted-file backend with an explicit key,
/// so a test can simulate "same file, different machine key" (which is what
/// a restored backup looks like).
fn file_backed_store(credentials_path: PathBuf, encryption_key: [u8; 32]) -> CredentialStore {
CredentialStore {
using_keyring: false,
credentials_path,
encryption_key,
}
}
/// A credentials file we cannot decrypt must read as *no credentials stored*,
/// not as a hard error. This is the restored-backup case: the ciphertext comes
/// back but the key that encrypted it (Android Keystore / the machine-derived
/// key) does not, so every read fails forever.
///
/// TRACES: UR-012 | IR-014
#[test]
fn undecryptable_credentials_file_reads_as_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CREDENTIALS_FILENAME);
let original = file_backed_store(path.clone(), [1u8; 32]);
original.save_to_file("user-1", "token-abc").unwrap();
// Restored onto a device whose derived key differs: same bytes, no key.
let restored = file_backed_store(path.clone(), [2u8; 32]);
match restored.get_token("user-1") {
Err(CredentialError::NotFound) => {}
other => panic!("expected NotFound for undecryptable ciphertext, got {other:?}"),
}
}
/// Garbage in the file (truncation, partial restore) is the same story.
///
/// TRACES: UR-012 | IR-014
#[test]
fn corrupt_credentials_file_reads_as_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CREDENTIALS_FILENAME);
fs::write(&path, "not base64 at all !!!").unwrap();
let store = file_backed_store(path, [3u8; 32]);
match store.get_token("user-1") {
Err(CredentialError::NotFound) => {}
other => panic!("expected NotFound for corrupt file, got {other:?}"),
}
}
/// …and the logged-out state must be recoverable: signing in again has to be
/// able to write over the unreadable file rather than failing on load.
///
/// TRACES: UR-012 | IR-014
#[test]
fn login_after_undecryptable_file_rewrites_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CREDENTIALS_FILENAME);
let original = file_backed_store(path.clone(), [1u8; 32]);
original.save_to_file("user-1", "token-abc").unwrap();
let restored = file_backed_store(path.clone(), [2u8; 32]);
restored.save_to_file("user-1", "token-fresh").unwrap();
assert_eq!(restored.get_from_file("user-1").unwrap(), "token-fresh");
}
#[test] #[test]
fn test_encryption_roundtrip() { fn test_encryption_roundtrip() {
let store = CredentialStore::new(); let store = CredentialStore::new();
+6 -5
View File
@@ -119,11 +119,12 @@ mod tests {
use super::*; use super::*;
fn item(name: &str, kind: MediaKind) -> MediaItem { fn item(name: &str, kind: MediaKind) -> MediaItem {
let mut item = MediaItem::default(); MediaItem {
item.id = format!("id-{}-{:?}", name, kind); id: format!("id-{}-{:?}", name, kind),
item.name = name.to_string(); name: name.to_string(),
item.kind = kind; kind,
item ..MediaItem::default()
}
} }
fn names(items: &[MediaItem]) -> Vec<&str> { fn names(items: &[MediaItem]) -> Vec<&str> {
+14 -8
View File
@@ -389,14 +389,18 @@ mod tests {
#[test] #[test]
fn test_queue_precache_config() { fn test_queue_precache_config() {
let mut config = CacheConfig::default(); let config = CacheConfig {
config.queue_precache_enabled = false; queue_precache_enabled: false,
..CacheConfig::default()
};
let cache = SmartCache::new(config); let cache = SmartCache::new(config);
assert!(!cache.should_precache_queue()); assert!(!cache.should_precache_queue());
let mut new_config = CacheConfig::default(); let new_config = CacheConfig {
new_config.wifi_only = false; wifi_only: false,
..CacheConfig::default()
};
cache.update_config(new_config); cache.update_config(new_config);
assert!(cache.should_precache_queue()); assert!(cache.should_precache_queue());
@@ -407,9 +411,11 @@ mod tests {
// wifi_only must not short-circuit precaching: the network gate lives in // wifi_only must not short-circuit precaching: the network gate lives in
// the download pump, which checks the *actual* transport. Enabling // the download pump, which checks the *actual* transport. Enabling
// WiFi-only while on WiFi should still precache. // WiFi-only while on WiFi should still precache.
let mut config = CacheConfig::default(); let config = CacheConfig {
config.queue_precache_enabled = true; queue_precache_enabled: true,
config.wifi_only = true; wifi_only: true,
..CacheConfig::default()
};
let cache = SmartCache::new(config); let cache = SmartCache::new(config);
assert!(cache.should_precache_queue()); assert!(cache.should_precache_queue());
@@ -422,7 +428,7 @@ mod tests {
/// TRACES: UR-071 | DR-127 | UT-120 /// TRACES: UR-071 | DR-127 | UT-120
#[tokio::test] #[tokio::test]
async fn test_reclaim_expired_only_takes_expired_temporary_entries() { async fn test_reclaim_expired_only_takes_expired_temporary_entries() {
use crate::storage::db_service::{DatabaseService, RusqliteService}; use crate::storage::db_service::RusqliteService;
use rusqlite::Connection; use rusqlite::Connection;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
+1
View File
@@ -9,6 +9,7 @@
pub mod cache; pub mod cache;
pub mod events; pub mod events;
pub mod network; pub mod network;
pub mod stop;
pub mod worker; pub mod worker;
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
+150
View File
@@ -0,0 +1,150 @@
//! Stop signalling for in-flight downloads.
//!
//! TRACES: UR-055 | DR-168
//!
//! Pausing and cancelling used to be database-only: `pause_download` wrote
//! `status = 'paused'` and nothing else. No cancellation existed anywhere in the
//! download stack — no token, no flag, no abort — so the streaming task kept
//! running, kept writing bytes, and on finishing overwrote the row with
//! `completed` or `failed`. The row flicked to "paused" and then undid itself,
//! which is precisely the reported "pause does not work".
//!
//! This is the missing half: a flag per in-flight download that the worker reads
//! between chunks. Setting it makes the worker return [`Stopped`] promptly and
//! leave the `.part` file **intact**, which is what lets a resume pick up from
//! where it stopped via the existing HTTP Range request.
//!
//! Kept as a module-level registry rather than on `DownloadManager` because the
//! two sides never meet: the command handler holds the manager's lock, while the
//! worker runs detached inside `tauri::async_runtime::spawn` with no access to
//! Tauri state. A registry both can reach is the smallest thing that works.
//!
//! [`Stopped`]: crate::download::worker::DownloadError::Stopped
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::utils::lock::MutexSafe;
/// download id → its stop flag, for downloads currently in flight.
fn registry() -> &'static Mutex<HashMap<i64, Arc<AtomicBool>>> {
static REGISTRY: OnceLock<Mutex<HashMap<i64, Arc<AtomicBool>>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Register `download_id` as in-flight and hand back its stop flag.
///
/// Called by the worker as it starts. A previous flag for the same id is
/// replaced, so a download that is paused and later resumed does not inherit the
/// set flag from its last run and stop immediately.
pub fn register(download_id: i64) -> Arc<AtomicBool> {
let flag = Arc::new(AtomicBool::new(false));
registry().lock_safe().insert(download_id, flag.clone());
flag
}
/// Ask an in-flight download to stop.
///
/// Returns whether one was actually in flight — the caller uses this to tell a
/// running download (which will stop shortly) from a merely queued one (which
/// the database update alone has already handled).
pub fn signal(download_id: i64) -> bool {
match registry().lock_safe().get(&download_id) {
Some(flag) => {
flag.store(true, Ordering::SeqCst);
true
}
None => false,
}
}
/// Forget a download's flag. Called when its task finishes, however it ended.
pub fn clear(download_id: i64) {
registry().lock_safe().remove(&download_id);
}
/// Whether a stop has been requested for `download_id`.
///
/// The worker reads its own `Arc<AtomicBool>` directly rather than looking the id
/// up, so this exists for the tests that assert the registry's behaviour.
#[cfg(test)]
pub fn is_stopping(download_id: i64) -> bool {
registry()
.lock_safe()
.get(&download_id)
.map(|f| f.load(Ordering::SeqCst))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
/// Ids are per-test so the shared registry cannot leak between them.
fn unique_id(seed: i64) -> i64 {
900_000 + seed
}
#[test]
fn test_a_registered_download_starts_unflagged() {
let id = unique_id(1);
let flag = register(id);
assert!(!flag.load(Ordering::SeqCst));
assert!(!is_stopping(id));
clear(id);
}
#[test]
fn test_signal_sets_the_flag_the_worker_reads() {
let id = unique_id(2);
let flag = register(id);
assert!(signal(id), "a registered download reports as in flight");
assert!(
flag.load(Ordering::SeqCst),
"the worker's own handle sees it"
);
assert!(is_stopping(id));
clear(id);
}
/// The pump only needs to abort a task that exists; a queued row is handled
/// by its database status alone.
#[test]
fn test_signalling_an_unregistered_download_reports_not_in_flight() {
assert!(!signal(unique_id(3)));
}
#[test]
fn test_clear_forgets_the_download() {
let id = unique_id(4);
register(id);
signal(id);
clear(id);
assert!(!is_stopping(id));
assert!(!signal(id), "a cleared download is no longer in flight");
}
/// The bug this guards: pause sets the flag, and resume re-runs the same
/// download id. If registering reused the old flag, the resumed run would see
/// a set flag and stop instantly — a download that could never be resumed.
#[test]
fn test_reregistering_clears_a_previous_stop() {
let id = unique_id(5);
register(id);
signal(id);
assert!(is_stopping(id));
let fresh = register(id);
assert!(!fresh.load(Ordering::SeqCst));
assert!(
!is_stopping(id),
"a resumed download must not inherit the pause"
);
clear(id);
}
}
+174 -15
View File
@@ -1,6 +1,7 @@
//! Download worker for HTTP streaming with progress tracking and retry logic //! Download worker for HTTP streaming with progress tracking and retry logic
use log::warn; use log::warn;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration; use std::time::Duration;
use futures_util::StreamExt; use futures_util::StreamExt;
@@ -31,10 +32,18 @@ impl DownloadWorker {
} }
} }
/// Download a file with retry logic and progress tracking /// Download a file with retry logic and progress tracking.
///
/// `stop` is the pause/cancel flag (see [`crate::download::stop`]). It is
/// checked between chunks and again between retries, so a paused download
/// stops promptly rather than after its next backoff — up to 45 seconds
/// away, which reads as the pause having done nothing.
///
/// TRACES: UR-055 | DR-168
pub async fn download<F>( pub async fn download<F>(
&self, &self,
task: &DownloadTask, task: &DownloadTask,
stop: &AtomicBool,
on_progress: F, on_progress: F,
) -> Result<DownloadResult, DownloadError> ) -> Result<DownloadResult, DownloadError>
where where
@@ -43,7 +52,10 @@ impl DownloadWorker {
let mut retries = 0; let mut retries = 0;
loop { loop {
match self.try_download(task, &on_progress).await { if stop.load(Ordering::SeqCst) {
return Err(DownloadError::Stopped);
}
match self.try_download(task, stop, &on_progress).await {
Ok(result) => return Ok(result), Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => { Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1; retries += 1;
@@ -63,6 +75,7 @@ impl DownloadWorker {
async fn try_download<F>( async fn try_download<F>(
&self, &self,
task: &DownloadTask, task: &DownloadTask,
stop: &AtomicBool,
on_progress: &F, on_progress: &F,
) -> Result<DownloadResult, DownloadError> ) -> Result<DownloadResult, DownloadError>
where where
@@ -76,7 +89,7 @@ impl DownloadWorker {
} }
// Check for partial download // Check for partial download
let temp_path = task.target_path.with_extension("part"); let temp_path = partial_path(&task.target_path);
let existing_bytes = if temp_path.exists() { let existing_bytes = if temp_path.exists() {
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0) fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
} else { } else {
@@ -100,22 +113,32 @@ impl DownloadWorker {
return Err(DownloadError::Http(response.status().as_u16())); return Err(DownloadError::Http(response.status().as_u16()));
} }
// Get content length // Did the server actually honour the Range? A transcode does not, and
// answers 200 with the whole stream — appending that would duplicate what
// we already hold. (DR-170)
let resume_from = resume_offset(existing_bytes, response.status().as_u16());
if existing_bytes > 0 && resume_from == 0 {
warn!(
"Server ignored the Range request (HTTP {}) — restarting {} from the beginning \
instead of appending to {} existing bytes",
response.status().as_u16(),
task.target_path.display(),
existing_bytes
);
}
// Get content length. Absent on a chunked transcode, which is why progress
// for a non-`original` preset has no percentage to show.
let _total_bytes = response let _total_bytes = response
.headers() .headers()
.get(reqwest::header::CONTENT_LENGTH) .get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok()) .and_then(|v| v.parse::<u64>().ok())
.map(|len| { .map(|len| len + resume_from);
if existing_bytes > 0 {
len + existing_bytes
} else {
len
}
});
// Open file for appending // Append only when resuming a range the server agreed to; otherwise
let mut file = if existing_bytes > 0 { // create/truncate so the restarted stream replaces the stale bytes.
let mut file = if resume_from > 0 {
fs::OpenOptions::new().append(true).open(&temp_path).await fs::OpenOptions::new().append(true).open(&temp_path).await
} else { } else {
fs::File::create(&temp_path).await fs::File::create(&temp_path).await
@@ -123,11 +146,22 @@ impl DownloadWorker {
.map_err(|e| DownloadError::FileSystem(e.to_string()))?; .map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// Stream download with progress tracking // Stream download with progress tracking
let mut downloaded = existing_bytes; let mut downloaded = resume_from;
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut last_progress_emit = std::time::Instant::now(); let mut last_progress_emit = std::time::Instant::now();
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
// Checked before writing, so a paused download stops on a byte
// boundary the `.part` file already accounts for — the Range request
// on resume then asks for exactly what is missing. Flushing what we
// have and leaving the file in place is the whole mechanism behind
// "resume", so this must never delete it. (DR-168)
if stop.load(Ordering::SeqCst) {
let _ = file.flush().await;
let _ = file.sync_all().await;
return Err(DownloadError::Stopped);
}
let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?; let chunk = chunk.map_err(|e| DownloadError::Network(e.to_string()))?;
file.write_all(&chunk) file.write_all(&chunk)
@@ -138,7 +172,7 @@ impl DownloadWorker {
// Emit progress every 500ms or every MB // Emit progress every 500ms or every MB
if last_progress_emit.elapsed() > Duration::from_millis(500) if last_progress_emit.elapsed() > Duration::from_millis(500)
|| downloaded % (1024 * 1024) == 0 || downloaded.is_multiple_of(1024 * 1024)
{ {
last_progress_emit = std::time::Instant::now(); last_progress_emit = std::time::Instant::now();
on_progress(downloaded, _total_bytes); on_progress(downloaded, _total_bytes);
@@ -167,6 +201,56 @@ impl DownloadWorker {
} }
} }
/// Where to resume writing a partial download, given how the server answered.
///
/// A byte offset of 0 means "start the file again"; anything else means "append
/// from here".
///
/// This is what makes non-`original` downloads survive. Those presets ask
/// Jellyfin to **transcode**, and a live transcode is chunked with no
/// `Content-Length` and cannot be byte-seeked: the server ignores `Range` and
/// answers `200` with the whole stream from the beginning, not `206` with the
/// requested tail. The worker sent the header and appended the body regardless,
/// so every retry — and every resume — concatenated a fresh copy of the whole
/// transcode onto the bytes already on disk. The file grew past its real size
/// and would not play. Only a `206` actually promises the tail; a `200` means we
/// must discard what we have and take the stream from the top.
///
/// TRACES: UR-071 | DR-170
pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
if existing_bytes == 0 {
return 0;
}
// 206 Partial Content is the only answer that honours the Range request.
if status == 206 {
existing_bytes
} else {
0
}
}
/// The partial-download sidecar for `target`.
///
/// **Appends** `.part` rather than replacing the extension. The worker used
/// `Path::with_extension("part")`, which replaces: `movie.mp4` became
/// `movie.part`. Every cleanup path meanwhile deleted `"{file_path}.part"` —
/// `movie.mp4.part` — so nothing ever matched and the partial file of every
/// cancelled or failed download was left on disk forever, invisible to the
/// disk-usage totals because no `downloads` row pointed at it. That is the
/// reported "failure is not cleaned".
///
/// Appending also removes a collision the old form had: `movie.mp4` and
/// `movie.mkv` both mapped to `movie.part` and would have fought over one file.
///
/// One function so the writer and the cleaners cannot disagree again.
///
/// TRACES: UR-055 | DR-169
pub fn partial_path(target: &std::path::Path) -> std::path::PathBuf {
let mut s = target.as_os_str().to_os_string();
s.push(".part");
std::path::PathBuf::from(s)
}
/// Result of a successful download /// Result of a successful download
#[derive(Debug)] #[derive(Debug)]
pub struct DownloadResult { pub struct DownloadResult {
@@ -179,6 +263,10 @@ pub enum DownloadError {
Network(String), Network(String),
Http(u16), Http(u16),
FileSystem(String), FileSystem(String),
/// The download was asked to stop (paused or cancelled). Not a failure: the
/// row's status already says what happened, and the partial file is kept so a
/// resume can continue from it.
Stopped,
} }
impl DownloadError { impl DownloadError {
@@ -188,8 +276,16 @@ impl DownloadError {
DownloadError::Network(_) => true, DownloadError::Network(_) => true,
DownloadError::Http(status) => *status >= 500, // Retry server errors DownloadError::Http(status) => *status >= 500, // Retry server errors
DownloadError::FileSystem(_) => false, DownloadError::FileSystem(_) => false,
// Retrying would restart the very download the user just paused.
DownloadError::Stopped => false,
} }
} }
/// Whether this outcome means "the user stopped it", rather than a failure to
/// record and report.
pub fn is_stopped(&self) -> bool {
matches!(self, DownloadError::Stopped)
}
} }
impl std::fmt::Display for DownloadError { impl std::fmt::Display for DownloadError {
@@ -198,6 +294,7 @@ impl std::fmt::Display for DownloadError {
DownloadError::Network(msg) => write!(f, "Network error: {}", msg), DownloadError::Network(msg) => write!(f, "Network error: {}", msg),
DownloadError::Http(status) => write!(f, "HTTP error {}", status), DownloadError::Http(status) => write!(f, "HTTP error {}", status),
DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg), DownloadError::FileSystem(msg) => write!(f, "File system error: {}", msg),
DownloadError::Stopped => write!(f, "Download stopped by request"),
} }
} }
} }
@@ -208,6 +305,64 @@ impl std::error::Error for DownloadError {}
mod tests { mod tests {
use super::*; use super::*;
/// The bitrate-download corruption: a transcode ignores `Range` and answers
/// `200` with the whole stream. Appending that to the bytes already on disk
/// duplicated them, so every retry grew the file past its real size and left
/// it unplayable. Only `206` promises the requested tail.
///
/// TRACES: UR-071 | DR-170 | UT-164
#[test]
fn test_resume_offset_only_appends_when_the_server_honoured_the_range() {
// Nothing on disk: start at the beginning either way.
assert_eq!(resume_offset(0, 200), 0);
assert_eq!(resume_offset(0, 206), 0);
// The server agreed to the range — append to what we have.
assert_eq!(resume_offset(5_000, 206), 5_000);
// The server ignored it and is sending the whole file (a transcode).
// Restart, or the bytes are duplicated.
assert_eq!(
resume_offset(5_000, 200),
0,
"a 200 carries the whole stream; appending it corrupts the file"
);
}
/// The regression: `with_extension` replaced the extension, so the worker
/// wrote `movie.part` while every cleanup path deleted `movie.mp4.part`.
/// Nothing matched, and partial files accumulated forever.
///
/// TRACES: UR-055 | DR-169 | UT-163
#[test]
fn test_partial_path_appends_rather_than_replacing_the_extension() {
use std::path::Path;
assert_eq!(
partial_path(Path::new("/media/movie.mp4")),
Path::new("/media/movie.mp4.part"),
"the cleanup paths delete \"{{file_path}}.part\"; this must produce it"
);
// Two sources for one title must not fight over a single partial file.
assert_ne!(
partial_path(Path::new("/media/movie.mp4")),
partial_path(Path::new("/media/movie.mkv")),
);
// Extension-less targets still get a sidecar rather than being clobbered.
assert_eq!(
partial_path(Path::new("/media/track")),
Path::new("/media/track.part"),
);
// A dotted name keeps every part of its own name.
assert_eq!(
partial_path(Path::new("/media/S01.E02.episode.mkv")),
Path::new("/media/S01.E02.episode.mkv.part"),
);
}
#[test] #[test]
fn test_exponential_backoff() { fn test_exponential_backoff() {
assert_eq!( assert_eq!(
@@ -231,5 +386,9 @@ mod tests {
assert!(DownloadError::Http(503).is_retryable()); assert!(DownloadError::Http(503).is_retryable());
assert!(!DownloadError::Http(404).is_retryable()); assert!(!DownloadError::Http(404).is_retryable());
assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable()); assert!(!DownloadError::FileSystem("disk full".to_string()).is_retryable());
// Retrying a paused download would restart what the user just stopped.
assert!(!DownloadError::Stopped.is_retryable());
assert!(DownloadError::Stopped.is_stopped());
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
} }
} }
+106 -19
View File
@@ -130,6 +130,7 @@ use commands::{
player_get_session, player_get_session,
player_get_sleep_timer, player_get_sleep_timer,
player_get_status, player_get_status,
player_get_streaming_qualities,
player_get_video_settings, player_get_video_settings,
// Preload commands // Preload commands
player_local_media_path, player_local_media_path,
@@ -159,6 +160,7 @@ use commands::{
player_set_cache_config, player_set_cache_config,
// Sleep timer and autoplay commands // Sleep timer and autoplay commands
player_set_sleep_timer, player_set_sleep_timer,
player_set_stream_quality,
player_set_subtitle_track, player_set_subtitle_track,
player_set_video_settings, player_set_video_settings,
player_set_volume, player_set_volume,
@@ -265,6 +267,7 @@ use commands::{
storage_save_user, storage_save_user,
storage_search_items, storage_search_items,
storage_set_active_user, storage_set_active_user,
storage_set_watched,
storage_toggle_favorite, storage_toggle_favorite,
storage_update_playback_context, storage_update_playback_context,
storage_update_playback_progress, storage_update_playback_progress,
@@ -311,6 +314,10 @@ use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig}; use jellyfin::{HttpClient, HttpConfig};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager; use playback_mode::PlaybackModeManager;
// Only the Android MediaSessionHandler resolves lockscreen skips; on other
// targets this would be an unused import.
#[cfg(target_os = "android")]
use player::seek::{resolve_skip_action, SkipAction};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter}; use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful // NullBackend is used both for platforms without a native backend AND as a graceful
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can // fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
@@ -424,22 +431,78 @@ impl MediaSessionHandler {
/// Drive the local player for a transport command. /// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) { fn handle_local_command(&self, command: &str) {
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
// whole episode — and resolving it during a background-audio handoff means
// re-opening the stream, which is async. So it runs on the runtime and,
// critically, is handled *before* the blocking lock below: taking that
// guard and then spawning a task that waits for the same mutex would
// deadlock the media session. (DR-159)
if let Some(raw) = command.strip_prefix("seek:") {
match raw.parse::<f64>() {
Ok(position) => {
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
if let Err(e) = controller.seek_absolute(position).await {
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
}
});
}
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
}
return;
}
// Skip means different things depending on what is actually playing, so
// the decision belongs here rather than in the Kotlin that drew the
// button: music advances the queue, while a video whose audio is running
// through a background-audio handoff scrubs instead (UR-040). Routed
// through the same spawn-and-seek path as "seek:" above, because
// `seek_absolute` rebuilds the stream during a handoff and must not run
// under the blocking lock (DR-159).
//
// TRACES: UR-040, UR-006 | DR-201
if command == "next" || command == "previous" {
let is_next = command == "next";
let player = self.player.clone();
tokio::spawn(async move {
let controller = player.lock().await;
let action = resolve_skip_action(
is_next,
controller.is_background_audio_active(),
controller.position(),
controller.duration(),
);
let label = if is_next { "next" } else { "previous" };
let result: Result<(), String> = match action {
SkipAction::Advance => if is_next {
controller.next()
} else {
controller.previous()
}
.map_err(|e| e.to_string()),
SkipAction::SeekTo(position) => {
info!(
"[MediaSession] Background audio: '{}' scrubs to {:.1}s",
label, position
);
controller.seek_absolute(position).await
}
};
if let Err(e) = result {
error!("[MediaSession] Skip '{}' failed: {}", label, e);
}
});
return;
}
// Use blocking_lock since this is called from a non-async JNI callback // Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock(); let controller = self.player.blocking_lock();
let result = match command { let result = match command {
"play" => controller.play(), "play" => controller.play(),
"pause" => controller.pause(), "pause" => controller.pause(),
"next" => controller.next(),
"previous" => controller.previous(),
"stop" => controller.stop(), "stop" => controller.stop(),
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(pos) => controller.seek(pos),
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => { _ => {
warn!("[MediaSession] Unknown command: {}", command); warn!("[MediaSession] Unknown command: {}", command);
Ok(()) Ok(())
@@ -602,7 +665,7 @@ fn create_player_backend(
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) { match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
Ok(backend) => { Ok(backend) => {
info!("Successfully initialized MPV backend for Linux"); info!("Successfully initialized MPV backend for Linux");
return Box::new(backend); Box::new(backend)
} }
Err(e) => { Err(e) => {
error!("\n========================================"); error!("\n========================================");
@@ -627,7 +690,7 @@ fn create_player_backend(
// still browse the library and manage downloads, and the frontend // still browse the library and manage downloads, and the frontend
// can show a "playback unavailable" notice via this event. // can show a "playback unavailable" notice via this event.
emit_backend_init_failed(&app_handle, "mpv", e.to_string()); emit_backend_init_failed(&app_handle, "mpv", e.to_string());
return Box::new(NullBackend::new()); Box::new(NullBackend::new())
} }
} }
} }
@@ -695,6 +758,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_get_eq_presets, player_get_eq_presets,
player_set_video_settings, player_set_video_settings,
player_get_video_settings, player_get_video_settings,
player_get_streaming_qualities,
player_set_stream_quality,
// Sleep timer and autoplay commands // Sleep timer and autoplay commands
player_set_sleep_timer, player_set_sleep_timer,
player_cancel_sleep_timer, player_cancel_sleep_timer,
@@ -789,6 +854,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
storage_update_playback_progress, storage_update_playback_progress,
storage_update_playback_context, storage_update_playback_context,
storage_mark_played, storage_mark_played,
storage_set_watched,
storage_get_playback_progress, storage_get_playback_progress,
storage_mark_synced, storage_mark_synced,
storage_toggle_favorite, storage_toggle_favorite,
@@ -1008,16 +1074,23 @@ fn set_env_if_unset(key: &str, value: &str) {
} }
} }
/// Downloaded media and cached thumbnails are handed to the webview as /// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that /// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
/// origin when the `protocol-asset` cargo feature is compiled in *and* /// origin when the `protocol-asset` cargo feature is compiled in *and*
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also /// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
/// scopes it to `$APPDATA/**` — the storage root holding the database, /// required together: with either missing the URL resolves to nothing and the
/// `downloads/` and the thumbnail cache. Both are required together: with either /// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
/// missing the URL resolves to nothing and the webview reports /// silently.
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
/// ///
/// TRACES: UR-071 | DR-134 /// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
/// moved to the loopback media server in DR-137, so `imageCache` is the only
/// remaining `convertFileSrc` caller and the database and the encrypted-token
/// fallback file — which share that root — never need to be readable by the
/// webview. Widen it only if something other than thumbnails starts resolving
/// through `convertFileSrc` again.
///
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logger // Initialize logger
@@ -1225,6 +1298,20 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default())); let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings); app.manage(video_settings);
// Restore the persisted streaming bandwidth ceiling. Deferred to the
// async runtime because the read is async, and ordered after the
// wrapper above because it writes into it. Until it lands, streams
// are uncapped — the pre-existing behaviour — and no playback can
// have started this early anyway (login happens after setup).
//
// TRACES: UR-074 | DR-162
{
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
crate::commands::restore_streaming_quality(&handle).await;
});
}
// Initialize thumbnail cache // Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache..."); info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
+214 -27
View File
@@ -27,6 +27,10 @@ const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// send a resume position, so a fresh track casts from 0 rather than ~0. /// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5; const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Volume level (0-100) the remote volume slider starts at. The real level is
/// corrected by the session poller once the remote session reports its volume.
const DEFAULT_REMOTE_VOLUME: i32 = 50;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to /// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start. /// hand to a remote session, or `None` if we're effectively at the start.
/// ///
@@ -42,6 +46,50 @@ fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
} }
} }
/// Platform hook for attaching/detaching the OS remote-volume control.
///
/// On Android, entering remote mode hands the `MediaSession` a
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
/// the *remote* session; leaving remote mode must hand it back to the local
/// media stream. Behind a trait so the routing rule (see
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
/// implementation is JNI and only exists on Android.
pub trait RemoteVolumeControl: Send + Sync {
/// Attach remote-volume control (and, on Android, start the playback service).
fn enable(&self, initial_volume: i32);
/// Return volume control to the local device speaker.
fn disable(&self);
}
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
struct PlatformRemoteVolumeControl;
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
#[allow(unused_variables)]
fn enable(&self, initial_volume: i32) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
}
fn disable(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - the mode change itself has already happened.
}
}
}
}
/// Manages playback mode transfers between local and remote sessions /// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager { pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -51,6 +99,8 @@ pub struct PlaybackModeManager {
/// Optional emitter used to notify the frontend when the mode changes, so its /// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests. /// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>, event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
/// Platform hook for OS-level remote volume routing (swapped in tests).
remote_volume: Arc<dyn RemoteVolumeControl>,
} }
impl PlaybackModeManager { impl PlaybackModeManager {
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)), current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)), is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)), event_emitter: Arc::new(Mutex::new(None)),
remote_volume: Arc::new(PlatformRemoteVolumeControl),
}
}
/// Construct with a custom remote-volume hook (tests).
#[cfg(test)]
fn with_remote_volume(
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
player_controller: Arc<TokioMutex<PlayerController>>,
remote_volume: Arc<dyn RemoteVolumeControl>,
) -> Self {
Self {
jellyfin_client,
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
remote_volume,
} }
} }
@@ -86,19 +154,39 @@ impl PlaybackModeManager {
/// the frontend's mirror store reconciles to this authoritative value. The /// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the /// write lock is released before emitting to avoid holding it across the
/// emitter call. /// emitter call.
///
/// Also owns **OS volume routing**, which is derived from the transition
/// rather than from each call site: entering remote mode attaches the remote
/// volume control, and *any* exit from remote mode hands it back to the local
/// speaker. Doing this per-call-site is what caused the bug where stopping a
/// remote session (`player_stop` → Idle) left Android stuck on the remote
/// volume slider — only the transfer-to-local path tore it down.
///
/// TRACES: UR-010 | DR-059, IR-021
pub fn set_mode(&self, mode: PlaybackMode) { pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode); log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let changed = { let (changed, was_remote) = {
let mut current = self.current_mode.write_safe(); let mut current = self.current_mode.write_safe();
let changed = *current != mode; let changed = *current != mode;
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
*current = mode.clone(); *current = mode.clone();
changed (changed, was_remote)
}; };
if !changed { if !changed {
return; return;
} }
// Volume routing follows the transition. Note remote->remote (switching
// target session) re-arms rather than releasing control.
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
if is_remote {
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
} else if was_remote {
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
self.remote_volume.disable();
}
let (mode_str, session_id) = match &mode { let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None), PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None), PlaybackMode::Idle => ("idle".to_string(), None),
@@ -122,18 +210,13 @@ impl PlaybackModeManager {
/// Both symptoms share this one cause, so this must not be skipped on any /// Both symptoms share this one cause, so this must not be skipped on any
/// remote-entry path (notably the empty-queue early return in /// remote-entry path (notably the empty-queue early return in
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing. /// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
#[allow(unused_variables)] ///
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
/// calling it again is harmless (the service start is idempotent) and keeps
/// the guarantee when the mode was already remote, which `set_mode` skips as
/// a no-op transition.
fn enable_remote_control(&self) { fn enable_remote_control(&self) {
#[cfg(target_os = "android")] self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
} }
/// Check if currently transferring /// Check if currently transferring
@@ -541,7 +624,7 @@ impl PlaybackModeManager {
); );
// Log first few track IDs for debugging // Log first few track IDs for debugging
if queue_ids.len() > 0 { if !queue_ids.is_empty() {
let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect(); let preview: Vec<&str> = queue_ids.iter().take(3).map(|s| s.as_str()).collect();
debug!("[PlaybackMode] First track IDs: {:?}...", preview); debug!("[PlaybackMode] First track IDs: {:?}...", preview);
} }
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
// This will be improved in Phase 3 when repository is migrated to Rust. // This will be improved in Phase 3 when repository is migrated to Rust.
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it"); log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
// Update mode to local // Update mode to local. This also returns volume control to the local
// device speaker — set_mode owns that for every exit from remote mode.
self.set_mode(PlaybackMode::Local); self.set_mode(PlaybackMode::Local);
// Disable remote volume control on Android (return to system volume)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
log::info!("[PlaybackMode] Successfully transferred to local"); log::info!("[PlaybackMode] Successfully transferred to local");
Ok(()) Ok(())
} }
@@ -839,7 +914,7 @@ mod tests {
impl PlayerEventEmitter for CapturingEmitter { impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) { fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event); self.events.lock_safe().push(event);
} }
} }
@@ -867,7 +942,7 @@ mod tests {
manager.set_mode(PlaybackMode::Local); manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle); manager.set_mode(PlaybackMode::Idle);
let events = emitter.events.lock().unwrap(); let events = emitter.events.lock_safe();
assert_eq!(events.len(), 3, "one event per real mode change"); assert_eq!(events.len(), 3, "one event per real mode change");
match &events[0] { match &events[0] {
@@ -893,6 +968,118 @@ mod tests {
} }
} }
/// Records enable/disable calls so tests can assert volume routing.
struct RecordingVolumeControl {
calls: Mutex<Vec<&'static str>>,
}
impl RemoteVolumeControl for RecordingVolumeControl {
fn enable(&self, _initial_volume: i32) {
self.calls.lock_safe().push("enable");
}
fn disable(&self) {
self.calls.lock_safe().push("disable");
}
}
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
let volume = Arc::new(RecordingVolumeControl {
calls: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::with_remote_volume(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
volume.clone(),
);
(manager, volume)
}
/// Leaving remote mode must hand volume control back to the local device.
///
/// Stopping a remote session (`player_stop`) drives the manager
/// Remote -> Idle without going through `transfer_to_local`. Before this was
/// centralised in `set_mode`, only the transfer path tore the Android
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
/// remote volume slider with no way back to the phone speaker.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_leaving_remote_mode_restores_local_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
// The stop path: remote -> idle, no transfer involved.
manager.set_mode(PlaybackMode::Idle);
assert_eq!(
*volume.calls.lock_safe(),
vec!["enable", "disable"],
"remote->idle must return volume control to the local speaker"
);
}
/// The same must hold for remote -> local (transfer back to this device).
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_remote_to_local_restores_local_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
assert_eq!(
*volume.calls.lock_safe(),
vec!["enable", "disable"],
"remote->local must return volume control to the local speaker"
);
}
/// Volume routing must not be touched by transitions that never involve
/// remote mode — an idle->local start would otherwise issue a pointless
/// `setPlaybackToLocal` on every playback start.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_non_remote_transitions_leave_volume_routing_alone() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
manager.set_mode(PlaybackMode::Local);
assert!(
volume.calls.lock_safe().is_empty(),
"local/idle transitions must not touch remote volume routing"
);
}
/// Switching directly between two remote sessions stays remote: control must
/// remain attached (re-armed for the new session), never handed back local.
///
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
#[test]
fn test_remote_to_remote_keeps_remote_volume() {
let (manager, volume) = manager_with_volume_control();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-2".to_string(),
});
assert_eq!(
*volume.calls.lock_safe(),
vec!["enable", "enable"],
"remote->remote re-arms control without releasing it to local"
);
}
/// Setting the same mode twice must not re-emit — the frontend reconciler /// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions. /// (and the event channel) shouldn't be spammed on no-op transitions.
#[test] #[test]
@@ -904,7 +1091,7 @@ mod tests {
manager.set_mode(PlaybackMode::Local); manager.set_mode(PlaybackMode::Local);
assert_eq!( assert_eq!(
emitter.events.lock().unwrap().len(), emitter.events.lock_safe().len(),
1, 1,
"repeated identical mode set emits only once" "repeated identical mode set emits only once"
); );
+29 -5
View File
@@ -420,7 +420,18 @@ impl PlayerBackend for ExoPlayerBackend {
None => JValue::Object(&null_obj), None => JValue::Object(&null_obj),
}; };
// Determine media type string for JNI // Determine media type string for JNI.
//
// This is not cosmetic: the string decides *which audio-focus mechanism*
// runs on the Kotlin side. `JellyTauPlayer.load()` re-applies
// `setAudioAttributes(attrs, handleAudioFocus = mediaType == AUDIO)`, so
// "audio" leaves focus to ExoPlayer (request on play, duck on transient
// loss, pause on a call) while "video" switches it to the manual
// `AudioFocusRequest` path, which needs delayed-focus handling. Either
// way the resulting pause comes back through `nativeOnStateChanged`, so
// the Rust controller — not the focus listener — stays authoritative.
//
// TRACES: UR-004, UR-006 | IR-008
let media_type_str = match media.media_type { let media_type_str = match media.media_type {
MediaType::Video => "video", MediaType::Video => "video",
MediaType::Audio => "audio", MediaType::Audio => "audio",
@@ -1096,6 +1107,15 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
/// ///
/// Commands from lockscreen controls, notification buttons, and Bluetooth /// Commands from lockscreen controls, notification buttons, and Bluetooth
/// devices are routed through here to the Rust PlayerController. /// devices are routed through here to the Rust PlayerController.
///
/// This is the inbound half of UR-006: `MediaSessionCompat` is flagged
/// `FLAG_HANDLES_MEDIA_BUTTONS`, so an AVRCP play/pause/skip from a headset
/// arrives at the service's transport callback and lands here as a command
/// string. The player stays authoritative — the session is a consumer that
/// *requests*, and the resulting state comes back out through
/// [`update_lockscreen_metadata`].
///
/// TRACES: UR-006 | IR-006
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand( pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand(
mut env: JNIEnv, mut env: JNIEnv,
@@ -1396,6 +1416,8 @@ use crate::player::LockscreenMetadata;
/// running (in remote mode it is started via [`enable_remote_volume`]); if it /// running (in remote mode it is started via [`enable_remote_volume`]); if it
/// isn't, this is a no-op rather than an error so it can be called freely on /// isn't, this is a no-op rather than an error so it can be called freely on
/// every poll tick. /// every poll tick.
///
/// TRACES: UR-006 | IR-006
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> { pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
@@ -1474,9 +1496,11 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(()) Ok(())
} }
/// Set the base position offset (seconds) on the lockscreen MediaSession. /// Set the background-audio handoff base (seconds) on the playback service.
/// ///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the /// The service holds it for `JellyTauPlayer`'s position tick, which is the one
/// place the relative handoff timeline is converted to the episode's own — see
/// DR-159. Calls `JellyTauPlaybackService.setHandoffBase(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally. /// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> { pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?; let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
@@ -1525,11 +1549,11 @@ pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
env.call_method( env.call_method(
&service_obj, &service_obj,
"setPositionOffset", "setHandoffBase",
"(D)V", "(D)V",
&[JValue::Double(offset_seconds)], &[JValue::Double(offset_seconds)],
) )
.map_err(|e| format!("Failed to set position offset: {}", e))?; .map_err(|e| format!("Failed to set handoff base: {}", e))?;
Ok(()) Ok(())
} }
+6
View File
@@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize};
/// Autoplay decision result - determines what happens after playback ends /// Autoplay decision result - determines what happens after playback ends
#[derive(specta::Type, Debug, Clone, Serialize)] #[derive(specta::Type, Debug, Clone, Serialize)]
#[serde(tag = "action", rename_all = "camelCase")] #[serde(tag = "action", rename_all = "camelCase")]
// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the unit
// variants. Boxing them is not worth it here: this enum is constructed once per
// end-of-item (never in a hot loop or a large collection), and it is an IPC type
// — the indirection would have to stay invisible to serde/specta while every
// match arm gained a deref, for no measurable gain.
#[allow(clippy::large_enum_variant)]
pub enum AutoplayDecision { pub enum AutoplayDecision {
/// Stop playback (no next item or timer expired) /// Stop playback (no next item or timer expired)
Stop, Stop,
+12 -6
View File
@@ -98,9 +98,12 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active audio track by stream index /// Set the active audio track by stream index
/// ///
/// @req-planned: UR-021 - Select audio track for video content /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// @req-planned: IR-019 - libmpv audio track selection /// does **not** override it — MPV is the audio-only backend here, so it keeps
/// @req-planned: DR-024 - Audio track selection UI in video player /// this `not_implemented()` default and the Linux video path switches track by
/// re-opening the stream instead (`player_switch_audio_track`).
///
/// TRACES: UR-021 | IR-019, DR-024
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> { fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
// Default implementation does nothing - override in platform-specific backends // Default implementation does nothing - override in platform-specific backends
Err(PlayerError::not_implemented()) Err(PlayerError::not_implemented())
@@ -108,9 +111,12 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active subtitle track by stream index (None to disable subtitles) /// Set the active subtitle track by stream index (None to disable subtitles)
/// ///
/// @req-planned: UR-020 - Select subtitles for video content /// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// @req-planned: IR-018 - libmpv subtitle rendering and selection /// does **not** override it, so it keeps this `not_implemented()` default;
/// @req-planned: DR-023 - Subtitle selection UI in video player /// the Linux video path renders subtitles as `<track>` children of the
/// WebKitGTK HTML5 `<video>` element and never calls this.
///
/// TRACES: UR-020 | IR-018, DR-023
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> { fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
// Default implementation does nothing - override in platform-specific backends // Default implementation does nothing - override in platform-specific backends
Err(PlayerError::not_implemented()) Err(PlayerError::not_implemented())
+6
View File
@@ -30,6 +30,12 @@ use super::{MediaSessionType, SleepTimerMode};
// queue_changed never reach the frontend, so the mini player never appears). // queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags. // Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the small
// position/state variants. Boxing them is rejected deliberately: this is a
// serde + specta wire type whose generated TypeScript must not shift, and the
// events are emitted a few times a second at most — never bulk-allocated — so
// the size difference costs nothing measurable.
#[allow(clippy::large_enum_variant)]
pub enum PlayerStatusEvent { pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback) /// Playback position updated (emitted periodically during playback)
PositionUpdate { PositionUpdate {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -243,7 +243,7 @@ impl MpvBackend {
}); });
} }
} }
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => { libmpv::events::Event::PropertyChange { name: "pause", .. } => {
// Handle pause state changes // Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") { if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state let media_id = state
+14 -13
View File
@@ -1,14 +1,15 @@
/// Tests for MpvBackend to prevent regressions //! Tests for MpvBackend to prevent regressions
/// //!
/// These tests are designed to catch common issues like: //! These tests are designed to catch common issues like:
/// - Tokio runtime panics when spawning async tasks from std::thread //! - Tokio runtime panics when spawning async tasks from std::thread
/// - Position update thread failures //! - Position update thread failures
/// - Event emission issues //! - Event emission issues
/// //!
/// TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004 //! TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
@@ -67,14 +68,14 @@ mod tests {
if let Ok(handle) = tokio::runtime::Handle::try_current() { if let Ok(handle) = tokio::runtime::Handle::try_current() {
// Has runtime (shouldn't happen in this test) // Has runtime (shouldn't happen in this test)
handle.spawn(async move { handle.spawn(async move {
*counter_clone.lock().unwrap() += 1; *counter_clone.lock_safe() += 1;
}); });
} else { } else {
// No runtime - use fallback (should happen in this test) // No runtime - use fallback (should happen in this test)
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move { rt.block_on(async move {
*counter_clone.lock().unwrap() += 1; *counter_clone.lock_safe() += 1;
}); });
}); });
} }
@@ -85,7 +86,7 @@ mod tests {
// Wait for async task to complete // Wait for async task to complete
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
let count = *counter.lock().unwrap(); let count = *counter.lock_safe();
assert_eq!( assert_eq!(
count, 1, count, 1,
"Fallback pattern should execute async code successfully" "Fallback pattern should execute async code successfully"
@@ -109,13 +110,13 @@ mod tests {
let position = i as f64 * 0.25; let position = i as f64 * 0.25;
// Store position (simulating event emission) // Store position (simulating event emission)
positions_clone.lock().unwrap().push(position); positions_clone.lock_safe().push(position);
} }
}); });
handle.join().unwrap(); handle.join().unwrap();
let recorded_positions = positions.lock().unwrap(); let recorded_positions = positions.lock_safe();
assert_eq!( assert_eq!(
recorded_positions.len(), recorded_positions.len(),
5, 5,
+1 -2
View File
@@ -806,11 +806,10 @@ mod tests {
assert_eq!(queue.current_index(), Some(first_shuffled_index)); assert_eq!(queue.current_index(), Some(first_shuffled_index));
// Move through shuffle order // Move through shuffle order
for i in 1..shuffle_order.len() { for &expected_index in &shuffle_order[1..] {
assert!(queue.has_next()); assert!(queue.has_next());
let result = queue.next(); let result = queue.next();
assert!(result.is_some()); assert!(result.is_some());
let expected_index = shuffle_order[i];
assert_eq!(queue.current_index(), Some(expected_index)); assert_eq!(queue.current_index(), Some(expected_index));
} }
+139
View File
@@ -59,10 +59,149 @@ pub fn determine_video_seek_strategy(
} }
} }
// The four items below are consumed by the Android MediaSessionHandler; on other
// targets only the tests exercise them, so dead-code analysis would flag them.
/// How far a lockscreen skip-forward jumps while background audio owns playback.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub const SKIP_FORWARD_SECONDS: f64 = 30.0;
/// How far a lockscreen skip-back jumps while background audio owns playback.
///
/// Deliberately shorter than the forward jump: the back button is used to replay
/// dialogue just missed, not to travel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub const SKIP_BACK_SECONDS: f64 = 10.0;
/// What a lockscreen skip button means for the playback that is actually running.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SkipAction {
/// Move to the next/previous queue entry — a track, or an episode.
Advance,
/// Scrub within the current item, to this absolute position in seconds.
SeekTo(f64),
}
/// Decide whether a lockscreen skip advances the queue or scrubs the current item.
///
/// Music gets queue advance, which is what the buttons look like they do. A video
/// whose audio is playing through a background-audio handoff (UR-040) gets a
/// relative scrub instead: there is no meaningful "next track" inside a film, and
/// jumping to the next *episode* because the user wanted to re-hear a line is a
/// much worse outcome than a scrub.
///
/// `is_background_audio` is the whole test, and it is sufficient on its own —
/// the handoff exists only for video, and an episode played through it reports
/// `MediaType::Audio`, so media type cannot distinguish this case (see the note
/// at `PlayerController::auto_advance_to_next_episode`).
///
/// Clamped to `[0, duration]` so a skip near either end lands in the item rather
/// than at a negative offset or past the end, which some backends treat as EOF
/// and would turn a scrub into an unintended advance.
///
/// TRACES: UR-040, UR-006 | DR-201
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn resolve_skip_action(
is_next: bool,
is_background_audio: bool,
position: f64,
duration: Option<f64>,
) -> SkipAction {
if !is_background_audio {
return SkipAction::Advance;
}
let target = if is_next {
position + SKIP_FORWARD_SECONDS
} else {
position - SKIP_BACK_SECONDS
};
let clamped = match duration {
Some(d) if d > 0.0 => target.clamp(0.0, d),
_ => target.max(0.0),
};
SkipAction::SeekTo(clamped)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// Music (no background-audio handoff) keeps queue advance on both buttons.
///
/// TRACES: UR-006 | DR-201 | UT-194
#[test]
fn test_skip_advances_queue_for_normal_audio() {
assert_eq!(
resolve_skip_action(true, false, 42.0, Some(300.0)),
SkipAction::Advance
);
assert_eq!(
resolve_skip_action(false, false, 42.0, Some(300.0)),
SkipAction::Advance
);
}
/// The reported bug: in background-audio mode the lockscreen skip buttons
/// advanced to the next/previous episode instead of scrubbing, so trying to
/// re-hear a line jumped out of the film entirely.
///
/// TRACES: UR-040 | DR-201 | UT-195
#[test]
fn test_skip_scrubs_in_background_audio_mode() {
assert_eq!(
resolve_skip_action(true, true, 100.0, Some(3600.0)),
SkipAction::SeekTo(130.0)
);
assert_eq!(
resolve_skip_action(false, true, 100.0, Some(3600.0)),
SkipAction::SeekTo(90.0)
);
}
/// Skipping back near the start clamps to zero rather than going negative,
/// which backends reject (the "Raw(-10)" class of error).
///
/// TRACES: UR-040 | DR-201 | UT-196
#[test]
fn test_skip_back_clamps_at_start() {
assert_eq!(
resolve_skip_action(false, true, 4.0, Some(3600.0)),
SkipAction::SeekTo(0.0)
);
}
/// Skipping forward near the end clamps to the duration instead of running
/// past it, which would read as end-of-stream and advance — the very thing
/// this function exists to prevent.
///
/// TRACES: UR-040 | DR-201 | UT-197
#[test]
fn test_skip_forward_clamps_at_end() {
assert_eq!(
resolve_skip_action(true, true, 3590.0, Some(3600.0)),
SkipAction::SeekTo(3600.0)
);
}
/// An unknown duration still scrubs, and still refuses to go negative.
///
/// TRACES: UR-040 | DR-201 | UT-198
#[test]
fn test_skip_without_duration_still_scrubs() {
assert_eq!(
resolve_skip_action(true, true, 10.0, None),
SkipAction::SeekTo(40.0)
);
assert_eq!(
resolve_skip_action(false, true, 3.0, None),
SkipAction::SeekTo(0.0)
);
}
/// Test video seek strategy for local files /// Test video seek strategy for local files
#[test] #[test]
fn test_seek_strategy_local_file() { fn test_seek_strategy_local_file() {
+3
View File
@@ -159,6 +159,9 @@ mod tests {
#[test] #[test]
fn test_end_reason_clone() { fn test_end_reason_clone() {
let reason = EndReason::Finished; let reason = EndReason::Finished;
// Deliberately exercising the derived `Clone` impl, not a plain copy:
// `EndReason` is also `Copy`, so clippy flags the call as redundant.
#[allow(clippy::clone_on_copy)]
let cloned = reason.clone(); let cloned = reason.clone();
assert_eq!(reason, cloned); assert_eq!(reason, cloned);
} }
+12
View File
@@ -144,6 +144,18 @@ impl ObservedTime {
live.filter(|p| *p >= 0.0).unwrap_or(self.position) live.filter(|p| *p >= 0.0).unwrap_or(self.position)
} }
/// The last observed position, with no live reading to prefer — the case
/// where the *reporter* is the only source there is (webview-rendered media,
/// which the native backend cannot see at all).
pub fn last_position(&self) -> f64 {
self.position
}
/// The last observed duration, if one was ever established.
pub fn last_duration(&self) -> Option<f64> {
self.duration
}
/// The live reading if there is one, else the last observed value. /// The live reading if there is one, else the last observed value.
pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> { pub fn duration_or_last(&self, live: Option<f64>) -> Option<f64> {
live.filter(|d| *d > 0.0).or(self.duration) live.filter(|d| *d > 0.0).or(self.duration)
@@ -196,7 +196,7 @@ mod tests {
impl PlayerEventEmitter for RecordingEmitter { impl PlayerEventEmitter for RecordingEmitter {
fn emit(&self, event: PlayerStatusEvent) { fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event); self.events.lock_safe().push(event);
} }
} }
@@ -244,7 +244,7 @@ mod tests {
let (mut b, events) = backend(); let (mut b, events) = backend();
b.load(&test_media()).unwrap(); b.load(&test_media()).unwrap();
let ev = events.lock().unwrap(); let ev = events.lock_safe();
let load = ev let load = ev
.iter() .iter()
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. })) .find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
@@ -263,7 +263,7 @@ mod tests {
b.pause().unwrap(); b.pause().unwrap();
b.seek(42.0).unwrap(); b.seek(42.0).unwrap();
let ev = events.lock().unwrap(); let ev = events.lock_safe();
assert!(ev.iter().any(|e| matches!( assert!(ev.iter().any(|e| matches!(
e, e,
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause" PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
+291 -15
View File
@@ -61,14 +61,147 @@ const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
/// it rather than give up. /// it rather than give up.
const FALLBACK_AUDIO_CODEC: &str = "aac"; const FALLBACK_AUDIO_CODEC: &str = "aac";
/// Jellyfin's sentinel for "negotiate no subtitle stream at all".
///
/// Omitting `SubtitleStreamIndex` does **not** mean this: the server then applies
/// the source's default/forced flags and picks a track itself. See
/// [`playback_subtitle_stream_index`] for why that is never what we want.
pub const NO_SUBTITLE_STREAM: i32 = -1;
/// Subtitle formats we can render ourselves, delivered as an external sidecar
/// track rather than painted into the video.
///
/// Every entry here is *text*. Image-based subtitles (PGS, DVD, DVB) are
/// deliberately absent: they are bitmaps, so the only way a server can show them
/// on a client that cannot composite them is to burn them into the picture.
const EXTERNAL_SUBTITLE_FORMATS: &[&str] = &["srt", "subrip", "ass", "ssa", "vtt"];
/// The `SubtitleProfile` entries to advertise, as `(format, method)`.
///
/// All `External`: the app fetches subtitle tracks itself and renders them over
/// the video (UR-020), so it never needs the server to composite them.
///
/// TRACES: UR-020 | DR-176 | UT-168
pub fn subtitle_profiles() -> Vec<(&'static str, &'static str)> {
EXTERNAL_SUBTITLE_FORMATS
.iter()
.map(|format| (*format, "External"))
.collect()
}
/// Whether asking the server to serve this subtitle codec forces it to burn the
/// subtitle into the picture.
///
/// Burn-in is not a subtitle cost — it is a *video* cost. It rules out remuxing
/// the video stream, so a source we would otherwise have passed through untouched
/// gets fully re-encoded frame by frame.
///
/// TRACES: UR-020 | DR-176 | UT-168
pub fn subtitle_forces_burn_in(codec: &str) -> bool {
!EXTERNAL_SUBTITLE_FORMATS
.iter()
.any(|format| format.eq_ignore_ascii_case(codec.trim()))
}
/// The `SubtitleStreamIndex` to negotiate with: always "none".
///
/// The reported bug: a source with an E-AC-3 track and a **PGSSUB** default
/// subtitle track. Sending no index let the server honour that default, and since
/// PGS cannot go out as a sidecar it chose `SubtitleMethod=Encode` — burn-in.
/// That turned an audio-only transcode (the HEVC video was directly supported)
/// into a full HEVC→h264 re-encode, which the server could not sustain in real
/// time: the buffer never grew beyond one segment and playback stalled every few
/// seconds, taking seeking down with it.
///
/// Asking for no subtitle stream costs nothing, because the app never wanted the
/// server's composited version — it fetches the text tracks separately and
/// renders them itself (UR-020).
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
pub fn playback_subtitle_stream_index() -> i32 {
NO_SUBTITLE_STREAM
}
/// Query keys through which a stream URL can carry a subtitle decision.
///
/// Jellyfin binds query keys case-insensitively, so the match has to be too —
/// the server itself mixes casing (`SubtitleStreamIndex` but
/// `alwaysBurnInSubtitleWhenTranscoding`).
const SUBTITLE_QUERY_KEYS: &[&str] = &[
"subtitlestreamindex",
"subtitlemethod",
"subtitlecodec",
"alwaysburninsubtitlewhentranscoding",
];
/// Rewrite a stream URL so it asks for no subtitle, whoever built it.
///
/// [`playback_subtitle_stream_index`] only governs the URLs *this app* builds.
/// When `PlaybackInfo` answers with a `TranscodingUrl`, the URL was built by the
/// server from its own subtitle verdict, and we play it verbatim — so a server
/// that picked a track anyway (a live channel opened without an index, a source
/// whose default is image-based) hands us `SubtitleMethod=Encode`, and the
/// burn-in the negotiation just declined comes back through the URL. Burn-in is
/// a *video* cost: it rules out remuxing and forces a full re-encode.
///
/// Stripping the keys is not enough on its own — an absent index is not "none",
/// it is "you choose" — so the sentinel is always appended.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
pub fn without_server_chosen_subtitle(url: &str) -> String {
let (path, query) = match url.split_once('?') {
Some((path, query)) => (path, query),
None => (url, ""),
};
let mut kept: Vec<&str> = query
.split('&')
.filter(|param| !param.is_empty())
.filter(|param| {
let key = param.split_once('=').map_or(*param, |(key, _)| key);
!SUBTITLE_QUERY_KEYS
.iter()
.any(|subtitle_key| key.eq_ignore_ascii_case(subtitle_key))
})
.collect();
let sentinel = format!("SubtitleStreamIndex={}", NO_SUBTITLE_STREAM);
kept.push(&sentinel);
format!("{}?{}", path, kept.join("&"))
}
/// Whether a subtitle in this format can reach the app as a sidecar it draws
/// itself — the same verdict as [`subtitle_forces_burn_in`], from the reader's
/// side, and the one a subtitle picker needs.
///
/// Since the app asks for burn-in nowhere (see
/// [`playback_subtitle_stream_index`]), a format that only burn-in could deliver
/// is one it can never display. An unnamed format is treated as undeliverable
/// rather than guessed at: offering a track and drawing nothing is worse than
/// not offering it.
///
/// TRACES: UR-020 | DR-176 | UT-168
pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
codec.is_some_and(|codec| !subtitle_forces_burn_in(codec))
}
/// Narrow a detected audio-codec list to what the renderer that will actually /// Narrow a detected audio-codec list to what the renderer that will actually
/// play the **video** can decode. /// play the **video** can decode.
/// ///
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer — /// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
/// but video does not play through ExoPlayer. Both Android and Linux render it /// but the webview `<video>` element may be what renders the video, and
/// in a webview `<video>` element, and Chromium/WebKit decode a much smaller set /// Chromium/WebKit decode a much smaller set than the platform does. Advertising
/// than the platform does. Advertising the raw list makes Jellyfin direct-play a /// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
/// track the webview cannot decode, and the user gets picture with no sound. /// the user gets picture with no sound.
///
/// Which renderer gets it is not fixed: Linux is always the element, and Android
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
/// the alternative is silence for everyone the switch lands the other way, which
/// is the bug this exists to prevent.
/// ///
/// The gap is widest on devices whose vendor licenses Dolby: a phone with /// The gap is widest on devices whose vendor licenses Dolby: a phone with
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent /// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
@@ -120,28 +253,152 @@ pub fn webview_can_decode_audio(codec: &str) -> bool {
/// delegate this decision; it knows what its own renderer can decode and must /// delegate this decision; it knows what its own renderer can decode and must
/// apply that itself. /// apply that itself.
/// ///
/// The track that matters is the one the server will actually serve: the /// The track that matters is the one the server will actually serve (see
/// default, or the first when none is marked. An unknown codec is left alone — /// [`served_audio_codec`]). An unknown codec is left alone — forcing a transcode
/// forcing a transcode on a guess would burn server CPU for files that play. /// on a guess would burn server CPU for files that play.
/// ///
/// TRACES: UR-004 | DR-149 | UT-148 /// TRACES: UR-004 | DR-149 | UT-148
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool { pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
let served = streams match served_audio_codec(streams) {
Some(codec) => !webview_can_decode_audio(codec),
// No audio at all, or a codec the server did not name: leave it alone.
None => false,
}
}
/// The codec of the audio track the server will actually serve, given the
/// source's audio streams as `(codec, is_default)` in source order: the default,
/// or the first when none is marked.
///
/// `None` means "nothing to judge" — no audio streams, or the server named no
/// codec for the one it would serve. Both callers of this rule treat that as
/// leave-well-alone, never as a licence to assume compatibility.
///
/// TRACES: UR-004, UR-071 | DR-149, DR-171 | UT-148, UT-166
pub fn served_audio_codec<'a>(streams: &[(Option<&'a str>, bool)]) -> Option<&'a str> {
streams
.iter() .iter()
.find(|(_, is_default)| *is_default) .find(|(_, is_default)| *is_default)
.or_else(|| streams.first()); .or_else(|| streams.first())
.and_then(|(codec, _)| *codec)
match served {
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
// No audio at all, or a codec the server did not name: leave it alone.
Some((None, _)) | None => false,
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
/// The reported bug, at the level it was decided: a source whose default
/// subtitle track is PGSSUB must not drag the video into a re-encode.
///
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn no_subtitle_stream_is_negotiated_so_the_server_never_burns_one_in() {
assert_eq!(playback_subtitle_stream_index(), NO_SUBTITLE_STREAM);
// Not `None`/omitted: that is what let the server pick the PGS track.
assert_eq!(playback_subtitle_stream_index(), -1);
}
/// A transcode URL the *server* built carries the server's own subtitle
/// verdict. Adopting it verbatim re-introduces the burn-in
/// [`playback_subtitle_stream_index`] exists to prevent — the negotiation
/// asks for no subtitle, and then we play a URL that asks for one anyway.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_server_built_transcode_url_has_its_burn_in_stripped() {
// Shape taken from Jellyfin's `StreamInfo.ToUrl`: it appends
// `SubtitleStreamIndex` and `SubtitleMethod` whenever it picked a track.
let served = "/videos/abc/master.m3u8?DeviceId=jt&MediaSourceId=src1\
&VideoCodec=h264&SubtitleMethod=Encode&SubtitleStreamIndex=2\
&PlaySessionId=xyz";
let url = without_server_chosen_subtitle(served);
assert!(
url.contains("SubtitleStreamIndex=-1"),
"the adopted URL must ask for no subtitle: {url}"
);
assert!(
!url.contains("SubtitleStreamIndex=2"),
"the server's chosen track must not survive: {url}"
);
assert!(
!url.contains("SubtitleMethod"),
"burn-in must not be requested: {url}"
);
// Everything else identifies the job and must survive untouched.
for kept in [
"DeviceId=jt",
"MediaSourceId=src1",
"VideoCodec=h264",
"PlaySessionId=xyz",
] {
assert!(url.contains(kept), "{kept} must survive: {url}");
}
}
/// The server may also be told to burn in unconditionally
/// (`alwaysBurnInSubtitleWhenTranscoding`), which is appended to the URL
/// rather than expressed as a method — and its keys are not PascalCase.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
let url = without_server_chosen_subtitle(
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
&subtitlestreamindex=3&SubtitleCodec=ass",
);
assert!(!url.to_lowercase().contains("alwaysburnin"), "{url}");
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
assert!(url.contains("api_key=k"), "{url}");
}
/// A URL the server built without any subtitle in it still has to *say* so:
/// omitting the index is what makes the server apply the source's default.
///
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
assert_eq!(
url,
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
);
// A bare URL is rare but must not come out malformed.
let bare = without_server_chosen_subtitle("/videos/abc/master.m3u8");
assert_eq!(bare, "/videos/abc/master.m3u8?SubtitleStreamIndex=-1");
}
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn text_subtitles_are_advertised_as_external_sidecars() {
let profiles = subtitle_profiles();
for format in ["srt", "subrip", "ass", "ssa", "vtt"] {
let entry = profiles.iter().find(|(f, _)| *f == format);
assert!(
entry.is_some(),
"{format} must be advertised or the server burns it into the picture"
);
assert_eq!(entry.unwrap().1, "External");
}
}
/// TRACES: UR-020 | DR-176 | UT-168
#[test]
fn text_subtitles_never_force_burn_in_but_image_ones_do() {
// Text: deliverable as a sidecar, so the video can still be remuxed.
assert!(!subtitle_forces_burn_in("subrip"));
assert!(!subtitle_forces_burn_in("ASS"));
assert!(!subtitle_forces_burn_in("ssa"));
// Image formats are bitmaps — the server can only composite them.
assert!(subtitle_forces_burn_in("PGSSUB"));
assert!(subtitle_forces_burn_in("dvdsub"));
}
#[test] #[test]
fn an_undecodable_default_track_forces_a_transcode() { fn an_undecodable_default_track_forces_a_transcode() {
// The reported bug: one E-AC-3 track, which the webview cannot decode. // The reported bug: one E-AC-3 track, which the webview cannot decode.
@@ -193,6 +450,25 @@ mod tests {
assert!(!audio_forces_transcode(&[(None, true)])); assert!(!audio_forces_transcode(&[(None, true)]));
} }
/// The download path needs the codec itself, not just the verdict, so it can
/// tell the server what to re-encode. It picks the same track the streaming
/// verdict is formed from — one rule, one place.
///
/// TRACES: UR-071 | DR-171 | UT-166
#[test]
fn the_served_codec_is_the_one_the_verdict_is_formed_from() {
assert_eq!(
served_audio_codec(&[(Some("aac"), false), (Some("eac3"), true)]),
Some("eac3")
);
assert_eq!(
served_audio_codec(&[(Some("eac3"), false), (Some("aac"), false)]),
Some("eac3")
);
assert_eq!(served_audio_codec(&[]), None);
assert_eq!(served_audio_codec(&[(None, true)]), None);
}
#[test] #[test]
fn a_dolby_device_does_not_advertise_dolby_for_video() { fn a_dolby_device_does_not_advertise_dolby_for_video() {
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played // The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
+137 -11
View File
@@ -79,22 +79,20 @@ impl HybridRepository {
self.online.get_jray_actors(item_id, t).await self.online.get_jray_actors(item_id, t).await
} }
/// Get video stream URL with optional seeking support. /// Get video stream URL. This method is online-only since offline playback
/// This method is online-only since offline playback uses local file paths. /// uses local file paths.
///
/// Takes no start position: the URL is an HLS playlist spanning the whole
/// item, and a position on it would 400 every segment — see
/// `OnlineRepository::get_video_stream_url`. Resume by seeking after load.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
&self, &self,
item_id: &str, item_id: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>, audio_stream_index: Option<i32>,
) -> Result<String, RepoError> { ) -> Result<String, RepoError> {
self.online self.online
.get_video_stream_url( .get_video_stream_url(item_id, media_source_id, audio_stream_index)
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await .await
} }
@@ -119,6 +117,40 @@ impl HybridRepository {
.await .await
} }
/// Every track of an album, asked of the **server** rather than the cache.
///
/// Deliberately not `get_items`, which is cache-first: it answers from SQLite
/// the moment the cache has any content. That is right for browsing and wrong
/// for deciding what to download, because a partial or unlinked cache then
/// decides how much of the album gets queued while the user is told the whole
/// album is downloading. Downloading is the one operation that must know the
/// album's *complete* contents.
///
/// Errors when the server cannot answer (offline); the caller falls back to
/// the local catalog and the rows are queued either way, resolving on
/// reconnect. Server results are written back to the cache, so browsing
/// benefits from the round trip too.
///
/// TRACES: UR-018, UR-055 | DR-173
pub async fn get_album_tracks(&self, album_id: &str) -> Result<Vec<MediaItem>, RepoError> {
let options = Some(GetItemsOptions {
include_item_types: Some(vec!["Audio".to_string()]),
sort_by: Some("ParentIndexNumber,IndexNumber,SortName".to_string()),
limit: Some(1000),
..Default::default()
});
let result = self.online.get_items(album_id, options).await?;
if !result.items.is_empty() {
if let Err(e) = self.offline.save_to_cache(album_id, &result.items).await {
warn!("[HybridRepo] Failed to cache album tracks: {:?}", e);
}
}
Ok(result.items)
}
/// Search only the local SQLite cache (downloaded content). /// Search only the local SQLite cache (downloaded content).
/// ///
/// Fast (100ms timeout) — used to render instant results before the server /// Fast (100ms timeout) — used to render instant results before the server
@@ -319,6 +351,49 @@ impl HybridRepository {
} }
} }
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
/// caller can refresh the cache in the background.
///
/// A plain cache hit answers from data that may be arbitrarily old, which
/// is right for the *response* and wrong for what it leaves behind: per-user
/// state (watch positions, favourites) only reaches the local tables when a
/// server result is cached, so a surface that always hits cache never learns
/// what another device did. `get_items` had a bespoke version of this; this
/// is the same idea, reusable.
///
/// The callback runs only on a cache hit — on a miss the server result is
/// already being fetched and cached by the normal path.
///
/// TRACES: UR-002, UR-025 | DR-155
async fn race_with_refresh<T, F1, F2, R>(
&self,
cache_future: F1,
server_future: F2,
on_cache_hit: R,
) -> Result<T, RepoError>
where
T: MeaningfulContent + Clone + Send + 'static,
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
R: FnOnce(),
{
let cache_result = cache_future.await;
if let Ok(data) = &cache_result {
if data.has_content() {
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
on_cache_hit();
return Ok(data.clone());
}
}
debug!("[HybridRepo] Cache miss, querying server");
match server_future.await {
Ok(data) => Ok(data),
Err(e) => cache_result.or(Err(e)),
}
}
/// Simple timeout wrapper for cache queries (100ms timeout) /// Simple timeout wrapper for cache queries (100ms timeout)
/// ///
/// @req: DR-013 - Repository pattern (cache-first with timeout) /// @req: DR-013 - Repository pattern (cache-first with timeout)
@@ -489,6 +564,21 @@ impl MediaRepository for HybridRepository {
} }
} }
/// A single item, cache-first — and, on a cache hit, refreshed in the
/// background so the stored copy keeps up with the server.
///
/// The background refresh is what carries per-user state home: caching an
/// item runs `mirror_user_data`, which is the only path by which a watch
/// position set on another device reaches the local `user_data` row the
/// resume check reads. Without it a cache hit returned this device's own
/// stale position forever and cross-device resume silently did nothing —
/// `get_items` already refreshes this way, so browsing a season worked
/// while opening the episode directly did not.
///
/// The refreshed value lands for the *next* read rather than this one: the
/// point of the cache-first race is to answer immediately.
///
/// TRACES: UR-025, UR-002 | DR-155 | UT-152
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> { async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let offline = Arc::clone(&self.offline); let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online); let online = Arc::clone(&self.online);
@@ -497,9 +587,32 @@ impl MediaRepository for HybridRepository {
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await }); let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
let online_for_refresh = Arc::clone(&self.online);
let offline_for_save = Arc::clone(&self.offline);
let refresh_id = item_id_clone.clone();
let on_cache_hit = move || {
tokio::spawn(async move {
match online_for_refresh.get_item(&refresh_id).await {
Ok(fresh) => {
// `save_to_cache` files the row under a parent; the item's
// own parent keeps it where a later listing expects it.
let parent = fresh
.parent_id
.clone()
.unwrap_or_else(|| "item".to_string());
if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
debug!("[HybridRepo] Background item refresh failed: {:?}", e);
}
}
Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
}
});
};
let server_future = async move { online.get_item(&item_id_clone).await }; let server_future = async move { online.get_item(&item_id_clone).await };
self.parallel_race(cache_future, server_future).await self.race_with_refresh(cache_future, server_future, on_cache_hit)
.await
} }
async fn get_latest_items( async fn get_latest_items(
@@ -791,10 +904,11 @@ impl MediaRepository for HybridRepository {
item_id: &str, item_id: &str,
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
source_audio_codec: Option<&str>,
) -> String { ) -> String {
// Always use online URL for downloads // Always use online URL for downloads
self.online self.online
.get_video_download_url(item_id, quality, media_source_id) .get_video_download_url(item_id, quality, media_source_id, source_audio_codec)
} }
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> { async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
@@ -1020,6 +1134,16 @@ impl MediaRepository for HybridRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
// `GATE_TEST_LOCK` below serialises the tests that flip the process-global
// `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately held across
// the `.await` of the repository call under test — that await *is* the
// critical section. This is not the production deadlock hazard the lint
// targets: the lock is test-only, uncontended outside these tests, and each
// `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
// cannot block another task on the same worker. Restructuring around it
// would reintroduce the flag race the lock exists to prevent.
#![allow(clippy::await_holding_lock)]
use super::*; use super::*;
use std::sync::Mutex; use std::sync::Mutex;
@@ -1218,6 +1342,7 @@ mod tests {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
unimplemented!() unimplemented!()
} }
@@ -1492,6 +1617,7 @@ mod tests {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
unimplemented!() unimplemented!()
} }
+53 -2
View File
@@ -197,14 +197,24 @@ pub trait MediaRepository: Send + Sync {
format: &str, format: &str,
) -> String; ) -> String;
/// Get video download URL (synchronous - just constructs URL) /// Build the URL a video download is fetched from. Synchronous — it only
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte) /// constructs a URL, so it stays testable without a server. Reach it through
/// [`resolve_video_download_url`] rather than calling it directly.
///
/// `source_audio_codec` is the codec of the audio track the server would
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
/// `original` quality it decides whether the file can be copied byte-for-byte
/// or has to have its audio re-encoded on the way down — a downloaded file is
/// played back with no server in reach, so it has to be decodable *here*.
///
/// TRACES: UR-071 | DR-171
#[allow(dead_code)] #[allow(dead_code)]
fn get_video_download_url( fn get_video_download_url(
&self, &self,
item_id: &str, item_id: &str,
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
source_audio_codec: Option<&str>,
) -> String; ) -> String;
/// Mark item as favorite /// Mark item as favorite
@@ -323,3 +333,44 @@ pub trait MediaRepository: Send + Sync {
new_index: u32, new_index: u32,
) -> Result<(), RepoError>; ) -> Result<(), RepoError>;
} }
/// The audio codec the server would serve for `item_id` — the default track, or
/// the first when none is marked, matching the track Jellyfin picks.
///
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
/// caller must read that as "unknown", never as "fine": it is the input to a
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
/// exactly as it was.
///
/// TRACES: UR-071 | DR-171 | UT-166
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
let item = repo.get_item(item_id).await.ok()?;
let audio: Vec<(Option<&str>, bool)> = item
.media_streams
.as_deref()
.unwrap_or_default()
.iter()
.filter(|s| s.stream_type == "Audio")
.map(|s| (s.codec.as_deref(), s.is_default))
.collect();
device_profile::served_audio_codec(&audio).map(str::to_string)
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171).
///
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
///
/// TRACES: UR-071 | DR-171
pub async fn resolve_video_download_url(
repo: &dyn MediaRepository,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
) -> String {
let codec = served_audio_codec(repo, item_id).await;
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
}
+380 -52
View File
@@ -665,40 +665,63 @@ impl OfflineRepository {
} }
/// Mirror the server's per-user state for an item into the local /// Mirror the server's per-user state for an item into the local
/// `user_data` table, so favourites marked on any other client are visible /// `user_data` table, so favourites marked and positions watched — on any
/// here — including offline, where the local table is the only source. /// other client are visible here, including offline, where the local table
/// is the only source.
/// ///
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the /// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
/// conflict rule: a toggle made while the server was unreachable is still /// conflict rule: a change made while the server was unreachable is still
/// waiting to be pushed, and must not be clobbered by the stale value the /// waiting to be pushed, and must not be clobbered by the stale value the
/// server is still reporting. Rows carrying no favourite state are skipped /// server is still reporting. For a position that means it is never pulled
/// entirely rather than written as `0`, which would fabricate an /// *backwards* by a server that has not yet heard where we got to.
/// "unfavourited" record from an endpoint that simply omits `UserData`.
/// ///
/// TRACES: UR-069 | DR-114 | UT-102 /// Each field is mirrored only when the server actually reported it —
/// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
/// absent, and a row with neither field is skipped outright rather than
/// written as zeroes, which would fabricate an "unfavourited, unwatched"
/// record from an endpoint that simply omits `UserData`.
///
/// The position half is what makes cross-device resume work: the resume
/// check reads this table alone, so before it was mirrored an item watched
/// elsewhere resumed from whatever *this* device last saw, or not at all.
///
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> { async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
let Some(is_favorite) = item.user_data.as_ref().and_then(|ud| ud.is_favorite) else { let user_data = item.user_data.as_ref();
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
// Nothing the server actually told us about — do not invent a row.
if is_favorite.is_none() && position_ticks.is_none() {
return Ok(()); return Ok(());
}; }
let query = Query::with_params( let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync) "INSERT INTO user_data
VALUES (?1, ?2, ?3, ?4, 0) (user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
VALUES (?1, ?2, ?3, ?4, ?5, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = excluded.is_favorite, is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
playback_position_ticks = COALESCE(
excluded.playback_position_ticks, user_data.playback_position_ticks),
synced_at = excluded.synced_at synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0", WHERE user_data.pending_sync = 0",
vec![ vec![
QueryParam::String(self.user_id.clone()), QueryParam::String(self.user_id.clone()),
QueryParam::String(item.id.clone()), QueryParam::String(item.id.clone()),
QueryParam::Int(if is_favorite { 1 } else { 0 }), is_favorite
.map(|f| QueryParam::Int(if f { 1 } else { 0 }))
.unwrap_or(QueryParam::Null),
position_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
QueryParam::String(now.to_string()), QueryParam::String(now.to_string()),
], ],
); );
// A missing item row (FK) is not fatal here — the favourite mirror is // A missing item row (FK) is not fatal here — the mirror is best-effort
// best-effort metadata, and failing the whole cache write over it would // metadata, and failing the whole cache write over it would break
// break browsing. // browsing.
if let Err(e) = self.db_service.execute(query).await { if let Err(e) = self.db_service.execute(query).await {
debug!( debug!(
"[OfflineRepo] user_data mirror skipped for {}: {}", "[OfflineRepo] user_data mirror skipped for {}: {}",
@@ -805,6 +828,32 @@ impl OfflineRepository {
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it /// the synced-but-not-downloaded catalog branch deliberately excluded, so it
/// is authoritative regardless of the process-wide catalog-browse flag. /// is authoritative regardless of the process-wide catalog-browse flag.
/// ///
/// Whether cached item `i` belongs to library `l`, decided by media kind.
///
/// The cache leaves `library_id`/`parent_id` NULL on every item
/// ([[offline-libraries-never-cached]]), so there is no link to follow: a
/// library's `collection_type` and an item's `item_type` are the only things
/// that can associate them. This is Jellyfin taxonomy and therefore lives in
/// Rust, never in the frontend.
///
/// It is a named constant because it is needed in two places that must agree
/// — which library *appears* in the Downloaded list, and which items appear
/// *inside* it. They disagreed: the listing query used this mapping while the
/// browse query only checked that the requested library existed, so opening
/// any library showed every downloaded top-level item on the server.
///
/// A library of some other (or unknown) type keeps everything, since there is
/// no mapping to narrow it by and hiding its contents would be worse.
///
/// TRACES: UR-055 | DR-082, DR-167
const LIBRARY_HOLDS_ITEM: &'static str = "(
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
OR l.collection_type IS NULL
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
)";
/// TRACES: UR-055 | DR-082, DR-083 /// TRACES: UR-055 | DR-082, DR-083
const DOWNLOADED_ITEMS_CTE: &'static str = " const DOWNLOADED_ITEMS_CTE: &'static str = "
WITH downloaded_items AS ( WITH downloaded_items AS (
@@ -885,6 +934,7 @@ impl OfflineRepository {
EXISTS ( EXISTS (
SELECT 1 FROM libraries l SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id WHERE l.id = ? AND l.server_id = i.server_id
AND {membership}
) )
-- Top-level only: hide leaves whose container is downloaded. -- Top-level only: hide leaves whose container is downloaded.
AND NOT EXISTS ( AND NOT EXISTS (
@@ -899,6 +949,7 @@ impl OfflineRepository {
ORDER BY i.sort_name ASC, i.name ASC ORDER BY i.sort_name ASC, i.name ASC
LIMIT {limit} OFFSET {start_index}", LIMIT {limit} OFFSET {start_index}",
cte = Self::DOWNLOADED_ITEMS_CTE, cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
); );
let query = Query::with_params( let query = Query::with_params(
@@ -943,7 +994,7 @@ impl OfflineRepository {
// We match a library by collection_type ↔ item_type instead: any // We match a library by collection_type ↔ item_type instead: any
// completed download of a given media kind qualifies that library. // completed download of a given media kind qualifies that library.
let query = Query::with_params( let query = Query::with_params(
&format!( format!(
"{cte} "{cte}
SELECT l.id, l.name, l.collection_type, l.image_tag SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l FROM libraries l
@@ -952,29 +1003,24 @@ impl OfflineRepository {
SELECT 1 FROM items i SELECT 1 FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = l.server_id WHERE i.server_id = l.server_id
AND ( AND {membership}
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
)
) )
ORDER BY l.sort_order ASC, l.name ASC", ORDER BY l.sort_order ASC, l.name ASC",
cte = Self::DOWNLOADED_ITEMS_CTE, cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
), ),
vec![QueryParam::String(self.server_id.clone())], vec![QueryParam::String(self.server_id.clone())],
); );
self.db_service self.db_service
.query_many(query, |row| { .query_many(query, |row| {
Ok(Library { Ok(Library::new(
id: row.get(0)?, row.get(0)?,
name: row.get(1)?, row.get(1)?,
collection_type: row row.get::<_, Option<String>>(2)?
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "unknown".to_string()), .unwrap_or_else(|| "unknown".to_string()),
image_tag: row.get(3)?, row.get(3)?,
}) ))
}) })
.await .await
.map_err(|e| RepoError::Database { message: e }) .map_err(|e| RepoError::Database { message: e })
@@ -1167,14 +1213,13 @@ impl MediaRepository for OfflineRepository {
self.db_service self.db_service
.query_many(query, |row| { .query_many(query, |row| {
Ok(Library { Ok(Library::new(
id: row.get(0)?, row.get(0)?,
name: row.get(1)?, row.get(1)?,
collection_type: row row.get::<_, Option<String>>(2)?
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| "unknown".to_string()), .unwrap_or_else(|| "unknown".to_string()),
image_tag: row.get(3)?, row.get(3)?,
}) ))
}) })
.await .await
.map_err(|e| RepoError::Database { message: e }) .map_err(|e| RepoError::Database { message: e })
@@ -1426,6 +1471,14 @@ impl MediaRepository for OfflineRepository {
FROM items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ? WHERE i.server_id = ? AND i.library_id = ?
-- Collapse leaves into the container that was added: a new
-- 14-track album should read as one album, not 14 songs. Only
-- drops a leaf when its own container is present in the same
-- result, so a standalone track or movie still appears.
AND NOT EXISTS (
SELECT 1 FROM downloaded_items parent
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
)
ORDER BY i.synced_at DESC ORDER BY i.synced_at DESC
LIMIT {}", limit_val LIMIT {}", limit_val
), ),
@@ -1928,6 +1981,7 @@ impl MediaRepository for OfflineRepository {
_item_id: &str, _item_id: &str,
_quality: &str, _quality: &str,
_media_source_id: Option<&str>, _media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String { ) -> String {
// Cannot download while offline // Cannot download while offline
String::new() String::new()
@@ -2456,6 +2510,16 @@ impl MediaRepository for OfflineRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
// `CATALOG_BROWSE_LOCK` below serialises the tests that flip the
// process-global `INCLUDE_CATALOG_BROWSE` flag, so its guard is deliberately
// held across the `.await` of the query under test — that await *is* the
// critical section. This is not the production deadlock hazard the lint
// targets: the lock is test-only, uncontended outside these tests, and each
// `#[tokio::test]` runs on its own single-threaded runtime, so a held guard
// cannot block another task on the same worker. Restructuring around it
// would reintroduce the flag race the lock exists to prevent.
#![allow(clippy::await_holding_lock)]
use super::*; use super::*;
use crate::storage::db_service::RusqliteService; use crate::storage::db_service::RusqliteService;
use rusqlite::Connection; use rusqlite::Connection;
@@ -2470,9 +2534,8 @@ mod tests {
static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static CATALOG_BROWSE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> { fn lock_catalog_browse() -> std::sync::MutexGuard<'static, ()> {
CATALOG_BROWSE_LOCK use crate::utils::lock::MutexSafe;
.lock() CATALOG_BROWSE_LOCK.lock_safe()
.unwrap_or_else(|poisoned| poisoned.into_inner())
} }
/// TRACES: UR-065 | DR-108 | UT-111 /// TRACES: UR-065 | DR-108 | UT-111
@@ -3410,18 +3473,13 @@ mod tests {
// Simulate the online path persisting the server's library list. // Simulate the online path persisting the server's library list.
let server_libs = vec![ let server_libs = vec![
Library { Library::new("music".into(), "Music".into(), "music".into(), None),
id: "music".into(), Library::new(
name: "Music".into(), "movies".into(),
collection_type: "music".into(), "Movies".into(),
image_tag: None, "movies".into(),
}, Some("tag".into()),
Library { ),
id: "movies".into(),
name: "Movies".into(),
collection_type: "movies".into(),
image_tag: Some("tag".into()),
},
]; ];
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap(); let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(saved, 2); assert_eq!(saved, 2);
@@ -3544,6 +3602,32 @@ mod tests {
.unwrap(); .unwrap();
} }
/// Like `insert_item`, but sets `library_id` — which `get_latest_items`
/// filters on, so rows without it are invisible to that query.
async fn insert_library_item(
db: &Arc<RusqliteService>,
id: &str,
item_type: &str,
library_id: &str,
album_id: Option<&str>,
) {
db.execute(Query::with_params(
"INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
vec![
QueryParam::String(id.to_string()),
QueryParam::String(library_id.to_string()),
QueryParam::String(format!("Name {id}")),
QueryParam::String(item_type.to_string()),
album_id
.map(|s| QueryParam::String(s.to_string()))
.unwrap_or(QueryParam::Null),
],
))
.await
.unwrap();
}
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) { async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
db.execute(Query::with_params( db.execute(Query::with_params(
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)", "INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
@@ -3578,6 +3662,36 @@ mod tests {
) )
} }
/// A newly-synced album appears once in "recently added", not once per track.
///
/// The downloaded-items CTE deliberately matches both the leaves and their
/// container, which is right for browsing but wrong here: it made a 3-track
/// album occupy 4 slots in the row. Tracks whose album is itself in the
/// result are now collapsed into it.
#[tokio::test]
async fn test_get_latest_items_collapses_tracks_into_their_album() {
let db = create_test_db();
insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
for track in ["track-1", "track-2", "track-3"] {
insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
seed_completed_download(&db, track, 1000).await;
}
// A movie has no container, so it must still show up on its own.
insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
seed_completed_download(&db, "movie-1", 2000).await;
let repo = make_repo(&db);
let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
assert!(
!ids.iter().any(|id| id.starts_with("track-")),
"individual tracks must collapse into their album, got: {ids:?}"
);
assert!(ids.contains(&"album-1"), "the album itself is listed");
assert!(ids.contains(&"movie-1"), "containerless items still listed");
}
/// UT: downloaded-only browse returns a downloaded leaf AND its container, /// UT: downloaded-only browse returns a downloaded leaf AND its container,
/// filtered to the requested album parent. A non-downloaded sibling is omitted. /// filtered to the requested album parent. A non-downloaded sibling is omitted.
/// ///
@@ -3633,6 +3747,82 @@ mod tests {
assert_eq!(track_ids, vec!["track-1", "track-2"]); assert_eq!(track_ids, vec!["track-1", "track-2"]);
} }
/// Regression: each downloaded library shows **only its own media**.
///
/// Cached items carry no link back to their library (`library_id`/`parent_id`
/// are NULL — [[offline-libraries-never-cached]]), and the library branch of
/// the query only asserted that the requested library *exists*, never that
/// the item belongs to it. So opening any downloaded library listed every
/// downloaded top-level item on the server: films in the music library,
/// albums under TV. The library's `collection_type` decides which item types
/// belong to it, the same mapping `get_downloaded_libraries` already uses.
///
/// TRACES: UR-055 | DR-167 | UT-162
#[tokio::test]
async fn test_get_downloaded_items_library_does_not_mix_media_types() {
let db = create_test_db();
seed_library(&db, "music-lib", "music").await;
seed_library(&db, "movie-lib", "movies").await;
seed_library(&db, "tv-lib", "tvshows").await;
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
insert_item(&db, "movie-1", "Movie", None, None, None).await;
insert_item(&db, "series-1", "Series", None, None, None).await;
insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
seed_completed_download(&db, "track-1", 1000).await;
seed_completed_download(&db, "movie-1", 2000).await;
seed_completed_download(&db, "episode-1", 3000).await;
let repo = make_repo(&db);
let music: Vec<String> = repo
.get_downloaded_items("music-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
music,
vec!["album-1"],
"the music library must not list films or series; got {:?}",
music
);
let movies: Vec<String> = repo
.get_downloaded_items("movie-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
movies,
vec!["movie-1"],
"the movie library must not list albums or series; got {:?}",
movies
);
let tv: Vec<String> = repo
.get_downloaded_items("tv-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
tv,
vec!["series-1"],
"the TV library must not list albums or films; got {:?}",
tv
);
}
/// Regression: a downloaded TV library lists the Series, not its Seasons or /// Regression: a downloaded TV library lists the Series, not its Seasons or
/// Episodes — the same "individual songs" bug seen for music, for TV. The /// Episodes — the same "individual songs" bug seen for music, for TV. The
/// season and episode are still reachable by drilling into the series. /// season and episode are still reachable by drilling into the series.
@@ -4374,4 +4564,142 @@ mod tests {
"an unsynced local toggle must survive a cache write" "an unsynced local toggle must survive a cache write"
); );
} }
/// UT-152 — the server's watch position is mirrored locally, so an item
/// watched on another device resumes here.
///
/// The resume check reads only the local `user_data` row, and the mirror
/// previously carried `is_favorite` alone — so a position set on any other
/// client never reached this device and cross-device resume silently did
/// nothing. The `pending_sync` guard is the same conflict rule favourites
/// use: a local position still waiting to be pushed must not be pulled
/// backwards by the stale value the server is still reporting.
///
/// TRACES: UR-025, UR-069 | DR-155 | UT-152
#[tokio::test]
async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let position = |id: &'static str| {
let db = db_service.clone();
async move {
db.query_optional(
Query::with_params(
"SELECT playback_position_ticks, pending_sync FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String(id.to_string()),
],
),
|row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
)
.await
.unwrap()
}
};
// Watched 20 minutes into this episode on another device.
let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
watched.user_data = Some(UserData {
playback_position_ticks: Some(12_000_000_000),
..Default::default()
});
// No user data at all — must not fabricate a position of 0.
let untouched = create_test_item("ep-2", "No User Data", None);
repo.save_to_cache("parent-1", &[watched.clone(), untouched])
.await
.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(12_000_000_000), Some(0))),
"the server's position should be mirrored as synced"
);
assert_eq!(
position("ep-2").await,
None,
"an item without UserData should not get an invented position"
);
// Watched further here while the server was unreachable: pending_sync = 1.
db_service
.execute(Query::with_params(
"UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::Int64(30_000_000_000),
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-1".to_string()),
],
))
.await
.unwrap();
// The server still reports the older position; caching must not win.
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
assert_eq!(
position("ep-1").await,
Some((Some(30_000_000_000), Some(1))),
"an unsynced local position must not be pulled backwards"
);
}
/// UT-152 — a server item carrying *only* a position (no favourite flag)
/// still gets mirrored.
///
/// The mirror used to return early whenever `is_favorite` was absent, which
/// is exactly the shape of an ordinary watched episode: Jellyfin reports
/// `PlaybackPositionTicks` with no favourite state. That early return is why
/// the position never landed.
///
/// TRACES: UR-025 | DR-155 | UT-152
#[tokio::test]
async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
let mut watched = create_test_item("ep-3", "Position Only", None);
watched.user_data = Some(UserData {
is_favorite: None,
playback_position_ticks: Some(9_000_000_000),
..Default::default()
});
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
let stored = db_service
.query_optional(
Query::with_params(
"SELECT playback_position_ticks FROM user_data \
WHERE user_id = ? AND item_id = ?",
vec![
QueryParam::String("test-user".to_string()),
QueryParam::String("ep-3".to_string()),
],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap();
assert_eq!(
stored,
Some(Some(9_000_000_000)),
"a position with no favourite flag must still be mirrored"
);
}
} }
File diff suppressed because it is too large Load Diff
+68 -4
View File
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
/// working through. /// working through.
/// 2. **The server's Next Up** for this series — it accounts for watch history /// 2. **The server's Next Up** for this series — it accounts for watch history
/// we do not cache locally. /// we do not cache locally.
/// 3. **The first unwatched episode** in series order. This is the offline path: /// 3. **The episode after the furthest-watched one**, falling back to the first
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without /// unwatched episode when nothing has been watched or the series is finished.
/// this rung the whole feature would be online-only. /// This is the offline path: `OfflineRepository::get_next_up_episodes`
/// returns an empty vec, so without this rung the whole feature would be
/// online-only. It deliberately does *not* return the first unwatched
/// episode outright — an unwatched episode behind the viewer's furthest
/// point was skipped on purpose, and sending them back to it is the bug
/// DR-101 was reopened for.
/// 4. **The first episode**, so a never-watched series opens on its premiere /// 4. **The first episode**, so a never-watched series opens on its premiere
/// rather than on nothing. /// rather than on nothing.
/// ///
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
return Some(matched.unwrap_or(found).clone()); return Some(matched.unwrap_or(found).clone());
} }
// 3. First unwatched in series order. // 3. The episode after the furthest-watched one. Not simply the first
// unwatched: a viewer who skipped the pilot but is deep into season 3
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
// where they stopped is the *last* thing they watched.
if let Some(furthest) = episodes.iter().rposition(is_played) {
if let Some(found) = episodes.get(furthest + 1) {
return Some(found.clone());
}
}
// Nothing watched yet (or the furthest-watched episode is the finale):
// the first unwatched episode in series order.
if let Some(found) = episodes.iter().find(|e| !is_played(e)) { if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
return Some(found.clone()); return Some(found.clone());
} }
@@ -352,6 +368,54 @@ mod tests {
assert_eq!(current.id, "s2e2"); assert_eq!(current.id, "s2e2");
} }
/// A viewer deep in season 3 who never watched the pilot must not be sent
/// back to it: the gap was a skip, not the place they stopped.
#[test]
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
for ep in eps.iter_mut() {
// Everything through S3E3 watched, except the never-watched pilot.
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
if watched_through && ep.id != "s1e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s3e4");
}
/// The furthest-watched episode being a finale must still roll into the
/// next season rather than stopping the series.
#[test]
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
let mut eps = [season(1, 3), season(2, 3)].concat();
for ep in eps.iter_mut() {
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s2e1");
}
/// Specials sort last, so watching one must not mark the series finished
/// while numbered episodes remain.
#[test]
fn a_watched_special_does_not_end_the_series() {
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
sort_series_order(&mut eps);
for ep in eps.iter_mut() {
if ep.id == "s1e1" || ep.id == "s0e1" {
*ep = watched(ep.clone());
}
}
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
assert_eq!(current.id, "s1e2");
}
#[test] #[test]
fn crosses_a_season_boundary_when_a_season_is_finished() { fn crosses_a_season_boundary_when_a_season_is_finished() {
let mut eps = [season(1, 3), season(2, 3)].concat(); let mut eps = [season(1, 3), season(2, 3)].concat();
+113
View File
@@ -36,6 +36,38 @@ pub struct Library {
pub collection_type: String, pub collection_type: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub image_tag: Option<String>, pub image_tag: Option<String>,
/// The favourites scope this library's contents fall under, or `None` for a
/// library kind favourites does not carve up (Live TV, channels, books…).
///
/// Derived here rather than in the UI: which collection type maps to which
/// scope is Jellyfin vocabulary, and the frontend must not hold a
/// collection-type → category table any more than an item-type one. See
/// `SearchScope::for_collection_type`.
///
/// TRACES: UR-075 | DR-175
#[serde(default, skip_serializing_if = "Option::is_none")]
pub favorites_scope: Option<SearchScope>,
}
impl Library {
/// Build a library, deriving everything that follows from its collection
/// type. Prefer this over the struct literal so a new derived field cannot
/// be forgotten at one of the construction sites.
pub fn new(
id: String,
name: String,
collection_type: String,
image_tag: Option<String>,
) -> Self {
let favorites_scope = SearchScope::for_collection_type(&collection_type);
Self {
id,
name,
collection_type,
image_tag,
favorites_scope,
}
}
} }
/// User-specific data for an item (playback state, favorites, etc.) /// User-specific data for an item (playback state, favorites, etc.)
@@ -221,6 +253,18 @@ pub struct MediaStream {
pub index: i32, pub index: i32,
pub is_default: bool, pub is_default: bool,
pub is_forced: bool, pub is_forced: bool,
/// Whether this stream can reach the app as a sidecar it renders itself.
///
/// `None` for anything that is not a subtitle — the question does not apply,
/// and `false` there would read like a verdict. For a subtitle it is the
/// difference between a track the app can draw and one only the server could
/// have shown, by burning it into the picture (DR-176) — which this app never
/// asks it to do. The vocabulary of *which formats those are* stays in Rust;
/// the frontend only reads the answer.
///
/// TRACES: UR-020 | DR-176 | UT-168
#[serde(default)]
pub supports_external_delivery: Option<bool>,
} }
/// Media source information /// Media source information
@@ -345,6 +389,27 @@ impl SearchScope {
), ),
} }
} }
/// The scope a library of this Jellyfin `CollectionType` belongs to, or
/// `None` when its contents are not something favourites are browsed by.
///
/// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
/// changes when Jellyfin renames a collection type, not when the library
/// page is redesigned — so it lives here rather than in the UI that renders
/// a per-library favourites tile.
///
/// `All` is never returned: it is the *absence* of a category, offered
/// alongside the libraries rather than derived from one.
///
/// TRACES: UR-075 | DR-175 | UT-161
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
match collection_type {
"movies" => Some(SearchScope::Movies),
"tvshows" => Some(SearchScope::Tv),
"music" => Some(SearchScope::Music),
_ => None,
}
}
} }
/// Options for search queries /// Options for search queries
@@ -653,6 +718,54 @@ mod search_scope_tests {
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap(); let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
assert!(matches!(all.scope, Some(SearchScope::All))); assert!(matches!(all.scope, Some(SearchScope::All)));
} }
/// TRACES: DR-175 | UT-161
#[test]
fn test_collection_type_maps_to_its_favorites_scope() {
assert_eq!(
SearchScope::for_collection_type("movies"),
Some(SearchScope::Movies)
);
assert_eq!(
SearchScope::for_collection_type("tvshows"),
Some(SearchScope::Tv)
);
assert_eq!(
SearchScope::for_collection_type("music"),
Some(SearchScope::Music)
);
}
/// A library kind favourites are not browsed by gets no tile at all, rather
/// than one that opens an unfiltered list. `All` is never derived from a
/// library — it is the cross-library entry offered beside them.
///
/// TRACES: DR-175 | UT-161
#[test]
fn test_uncategorised_collection_types_have_no_favorites_scope() {
for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
assert_eq!(
SearchScope::for_collection_type(collection_type),
None,
"{collection_type} should not carry a favourites scope"
);
}
}
/// TRACES: DR-175 | UT-161
#[test]
fn test_library_carries_its_favorites_scope_to_the_frontend() {
let music = Library::new("1".into(), "Music".into(), "music".into(), None);
assert_eq!(music.favorites_scope, Some(SearchScope::Music));
let json = serde_json::to_value(&music).unwrap();
assert_eq!(json["favoritesScope"], "music");
// A library with no scope omits the field rather than sending null.
let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
let json = serde_json::to_value(&livetv).unwrap();
assert!(json.get("favoritesScope").is_none());
}
} }
#[cfg(test)] #[cfg(test)]
+228
View File
@@ -148,6 +148,138 @@ impl AudioSettings {
} }
} }
/// A ceiling on how much bandwidth a *video* stream may consume.
///
/// A quality step is a bundle of concrete transcode parameters — total stream
/// ceiling, the audio share of it, and the resolution that ceiling can carry —
/// not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
/// they live here and the frontend only ever names a variant; the labels the
/// picker shows are served over IPC by `player_get_streaming_qualities`.
///
/// The ladder is deliberately expressed in bandwidth rather than resolution: it
/// exists to fit a connection, and the resolution cap is chosen *from* the
/// bitrate so the encoder does not spend a small budget on pixels it cannot
/// afford. See docs/specs/streaming-bitrate-cap.md.
///
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StreamingQuality {
/// No client-imposed cap — the server may direct-play the source as-is.
#[default]
Original,
Mbps20,
Mbps10,
Mbps8,
Mbps4,
Mbps2,
Mbps1,
Kbps720,
}
impl StreamingQuality {
/// The ladder, highest first, for enumerating across the IPC boundary.
pub const ALL: [StreamingQuality; 8] = [
StreamingQuality::Original,
StreamingQuality::Mbps20,
StreamingQuality::Mbps10,
StreamingQuality::Mbps8,
StreamingQuality::Mbps4,
StreamingQuality::Mbps2,
StreamingQuality::Mbps1,
StreamingQuality::Kbps720,
];
/// Total bits per second the stream may use (video + audio), or `None` for
/// the uncapped `Original`.
///
/// This is the number that goes to `PlaybackInfo` as `MaxStreamingBitrate`
/// and into the device profile. Sending it there — not just on the transcode
/// URL — is what makes the cap real: a stream the server decides to *direct
/// play* is served at the source file's own bitrate, and no URL parameter
/// afterwards can reduce it.
pub fn max_bitrate(&self) -> Option<u64> {
match self {
StreamingQuality::Original => None,
StreamingQuality::Mbps20 => Some(20_000_000),
StreamingQuality::Mbps10 => Some(10_000_000),
StreamingQuality::Mbps8 => Some(8_000_000),
StreamingQuality::Mbps4 => Some(4_000_000),
StreamingQuality::Mbps2 => Some(2_000_000),
StreamingQuality::Mbps1 => Some(1_000_000),
StreamingQuality::Kbps720 => Some(720_000),
}
}
/// Bits per second allotted to the audio track.
///
/// The value shrinks with the ladder because at the bottom rungs a fixed
/// 384 kbps would be a third of the entire budget.
pub fn audio_bitrate(&self) -> u64 {
match self {
StreamingQuality::Original
| StreamingQuality::Mbps20
| StreamingQuality::Mbps10
| StreamingQuality::Mbps8 => 384_000,
StreamingQuality::Mbps4 => 256_000,
StreamingQuality::Mbps2 => 192_000,
StreamingQuality::Mbps1 => 128_000,
StreamingQuality::Kbps720 => 96_000,
}
}
/// Bits per second allotted to the video track: the total minus the audio
/// share, so the two together honour [`max_bitrate`](Self::max_bitrate)
/// rather than overshooting it by the size of the audio track.
pub fn video_bitrate(&self) -> Option<u64> {
self.max_bitrate()
.map(|total| total.saturating_sub(self.audio_bitrate()))
}
/// Resolution ceiling that suits the bitrate, or `None` to leave the source
/// resolution alone. Scaling down is what keeps a small budget looking like
/// clean video instead of blocky 1080p.
pub fn max_height(&self) -> Option<u32> {
match self {
// 20 Mbps carries 4K, so it caps bandwidth without capping pixels.
StreamingQuality::Original | StreamingQuality::Mbps20 => None,
StreamingQuality::Mbps10 | StreamingQuality::Mbps8 => Some(1080),
StreamingQuality::Mbps4 | StreamingQuality::Mbps2 => Some(720),
StreamingQuality::Mbps1 => Some(480),
StreamingQuality::Kbps720 => Some(360),
}
}
/// Human label for the picker. Lives in Rust with the numbers it describes,
/// so the two cannot drift apart.
pub fn label(&self) -> &'static str {
match self {
StreamingQuality::Original => "Original",
StreamingQuality::Mbps20 => "20 Mbps",
StreamingQuality::Mbps10 => "10 Mbps",
StreamingQuality::Mbps8 => "8 Mbps",
StreamingQuality::Mbps4 => "4 Mbps",
StreamingQuality::Mbps2 => "2 Mbps",
StreamingQuality::Mbps1 => "1 Mbps",
StreamingQuality::Kbps720 => "720 kbps",
}
}
/// Secondary line for the picker: what the cap means in practice.
pub fn detail(&self) -> &'static str {
match self {
StreamingQuality::Original => "No limit — highest quality",
StreamingQuality::Mbps20 => "Up to 4K",
StreamingQuality::Mbps10 => "1080p, high quality",
StreamingQuality::Mbps8 => "1080p",
StreamingQuality::Mbps4 => "720p",
StreamingQuality::Mbps2 => "720p, reduced",
StreamingQuality::Mbps1 => "480p",
StreamingQuality::Kbps720 => "360p — slowest connections",
}
}
}
/// Video playback settings /// Video playback settings
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -159,6 +291,14 @@ pub struct VideoSettings {
/// Maximum number of episodes to auto-play consecutively (0 = unlimited) /// Maximum number of episodes to auto-play consecutively (0 = unlimited)
#[serde(default)] #[serde(default)]
pub auto_play_max_episodes: u32, pub auto_play_max_episodes: u32,
/// Bandwidth ceiling applied to every video stream.
///
/// `#[serde(default)]` so settings JSON persisted before this field existed
/// loads as the previous behaviour (uncapped).
///
/// TRACES: UR-074 | DR-162
#[serde(default)]
pub streaming_quality: StreamingQuality,
} }
impl Default for VideoSettings { impl Default for VideoSettings {
@@ -167,6 +307,7 @@ impl Default for VideoSettings {
auto_play_next_episode: true, auto_play_next_episode: true,
auto_play_countdown_seconds: 10, auto_play_countdown_seconds: 10,
auto_play_max_episodes: 0, auto_play_max_episodes: 0,
streaming_quality: StreamingQuality::Original,
} }
} }
} }
@@ -427,12 +568,14 @@ mod tests {
auto_play_next_episode: false, auto_play_next_episode: false,
auto_play_countdown_seconds: 15, auto_play_countdown_seconds: 15,
auto_play_max_episodes: 5, auto_play_max_episodes: 5,
streaming_quality: StreamingQuality::Mbps4,
}; };
let json = serde_json::to_string(&settings).unwrap(); let json = serde_json::to_string(&settings).unwrap();
assert!(json.contains("\"autoPlayNextEpisode\":false")); assert!(json.contains("\"autoPlayNextEpisode\":false"));
assert!(json.contains("\"autoPlayCountdownSeconds\":15")); assert!(json.contains("\"autoPlayCountdownSeconds\":15"));
assert!(json.contains("\"autoPlayMaxEpisodes\":5")); assert!(json.contains("\"autoPlayMaxEpisodes\":5"));
assert!(json.contains("\"streamingQuality\":\"mbps4\""));
let parsed: VideoSettings = serde_json::from_str(&json).unwrap(); let parsed: VideoSettings = serde_json::from_str(&json).unwrap();
assert!(!parsed.auto_play_next_episode); assert!(!parsed.auto_play_next_episode);
@@ -448,5 +591,90 @@ mod tests {
assert!(parsed.auto_play_next_episode); assert!(parsed.auto_play_next_episode);
assert_eq!(parsed.auto_play_countdown_seconds, 10); assert_eq!(parsed.auto_play_countdown_seconds, 10);
assert_eq!(parsed.auto_play_max_episodes, 0); assert_eq!(parsed.auto_play_max_episodes, 0);
// Settings persisted before the cap existed must load as uncapped —
// inventing a limit for an upgrading user would silently degrade their
// picture with no setting having been changed.
assert_eq!(parsed.streaming_quality, StreamingQuality::Original);
}
/// The whole point of a step is the number of bits it promises not to
/// exceed, so video + audio must fit inside the total — a video bitrate set
/// to the full cap would overshoot it by the size of the audio track.
///
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_budget_is_internally_consistent() {
for quality in StreamingQuality::ALL {
let Some(total) = quality.max_bitrate() else {
assert_eq!(
quality,
StreamingQuality::Original,
"only Original may be uncapped"
);
assert!(quality.video_bitrate().is_none());
assert!(quality.max_height().is_none());
continue;
};
let video = quality.video_bitrate().expect("a capped step caps video");
assert_eq!(
video + quality.audio_bitrate(),
total,
"{:?}: video + audio must equal the cap",
quality
);
assert!(
video > 0,
"{:?}: audio must not consume the budget",
quality
);
assert!(!quality.label().is_empty());
assert!(!quality.detail().is_empty());
}
}
/// The ladder is presented to the user as descending, and the resolution cap
/// must fall with it — a lower bitrate paired with a higher resolution would
/// spend the smaller budget on more pixels, which is backwards.
///
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_ladder_descends() {
let steps = StreamingQuality::ALL;
for pair in steps.windows(2) {
let (higher, lower) = (pair[0], pair[1]);
let higher_bitrate = higher.max_bitrate().unwrap_or(u64::MAX);
let lower_bitrate = lower.max_bitrate().unwrap_or(u64::MAX);
assert!(
higher_bitrate > lower_bitrate,
"{:?} must sit above {:?}",
higher,
lower
);
assert!(
higher.max_height().unwrap_or(u32::MAX) >= lower.max_height().unwrap_or(u32::MAX),
"{:?} must not cap resolution below {:?}",
higher,
lower
);
assert!(higher.audio_bitrate() >= lower.audio_bitrate());
}
}
/// The persisted form is the serde token, and it must survive a round trip —
/// a rename here silently resets everyone's saved cap to uncapped.
///
/// TRACES: UR-074 | DR-162 | UT-157
#[test]
fn test_streaming_quality_round_trips_through_json() {
for quality in StreamingQuality::ALL {
let json = serde_json::to_string(&quality).expect("serialises");
let parsed: StreamingQuality = serde_json::from_str(&json).expect("parses back");
assert_eq!(parsed, quality);
}
assert_eq!(
serde_json::to_string(&StreamingQuality::Mbps10).unwrap(),
"\"mbps10\""
);
} }
} }
+4
View File
@@ -155,6 +155,10 @@ impl ThumbnailCache {
} }
/// Save thumbnail to cache /// Save thumbnail to cache
// The arguments are the cache key (item/type/tag) plus the payload and its
// dimensions — all independent scalars borrowed from the caller. A parameter
// struct would only move the same list one level down.
#[allow(clippy::too_many_arguments)]
pub async fn save_thumbnail( pub async fn save_thumbnail(
&self, &self,
db: Arc<RusqliteService>, db: Arc<RusqliteService>,
+4 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau", "productName": "jellytau",
"version": "0.4.8", "version": "0.8.0",
"identifier": "com.dtourolle.jellytau", "identifier": "com.dtourolle.jellytau",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -18,10 +18,11 @@
} }
], ],
"security": { "security": {
"csp": null, "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["$APPDATA/**"] "scope": ["$APPDATA/thumbnails/**"]
} }
} }
}, },
+226 -13
View File
@@ -118,16 +118,41 @@ async playerSetVolume(volume: number) : Promise<PlayerStatus> {
async playerToggleMute() : Promise<PlayerStatus> { async playerToggleMute() : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_toggle_mute"); return await TAURI_INVOKE("player_toggle_mute");
}, },
/**
* Set the active audio track on a native backend directly.
*
* TRACES: UR-021 | IR-019, DR-024
*/
async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> { async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_audio_track", { streamIndex }); return await TAURI_INVOKE("player_set_audio_track", { streamIndex });
}, },
/** /**
* Switch audio track - handles both HTML5 (stream reload) and native (direct switch) * Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
* Note: Frontend should handle saving series preferences after this command succeeds * Note: Frontend should handle saving series preferences after this command succeeds
*
* The split is the requirement: an HTML5 `<video>` element cannot be told to
* change audio track, so the stream is re-opened at the chosen
* `AudioStreamIndex` and the frontend seeks the reloaded element back to
* `position`; a native backend (ExoPlayer) switches in place by track-group
* index. libmpv implements neither it is the audio-only backend here and
* leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
* which is why IR-019 is met by these two paths rather than by MPV.
*
* TRACES: UR-021 | IR-019, DR-024
*/ */
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> { async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId }); return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
}, },
/**
* Set (or clear, with `None`) the active subtitle track on a native backend.
*
* On Android this indexes ExoPlayer's *text track groups* i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>`
* children. libmpv implements neither, leaving the trait default in place.
*
* TRACES: UR-020 | IR-018, DR-023
*/
async playerSetSubtitleTrack(streamIndex: number | null) : Promise<PlayerStatus> { async playerSetSubtitleTrack(streamIndex: number | null) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_subtitle_track", { streamIndex }); return await TAURI_INVOKE("player_set_subtitle_track", { streamIndex });
}, },
@@ -197,6 +222,40 @@ async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
async playerGetVideoSettings() : Promise<VideoSettings> { async playerGetVideoSettings() : Promise<VideoSettings> {
return await TAURI_INVOKE("player_get_video_settings"); return await TAURI_INVOKE("player_get_video_settings");
}, },
/**
* The bandwidth ceilings the quality picker may offer, each with the label and
* one-line detail to show for it, highest first.
*
* The ladder and its numbers are Jellyfin encoding domain vocabulary, so the
* frontend reads them here rather than encoding them the same arrangement as
* [`player_get_eq_presets`].
*
* TRACES: UR-074 | DR-162
*/
async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string])[]> {
return await TAURI_INVOKE("player_get_streaming_qualities");
},
/**
* Change the bandwidth ceiling of the video that is playing *right now*.
*
* A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight the stream has to
* be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
*
* The change applies to this playback *and* to everything started afterwards
* (it sets the process-wide ceiling), but it is deliberately **not** persisted:
* the in-player picker is a "this film, this connection" control, and the
* durable default belongs to Settings. `player_set_video_settings` is the one
* that writes to the database.
*
* TRACES: UR-074 | DR-162
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
},
/** /**
* Set sleep timer mode * Set sleep timer mode
*/ */
@@ -746,6 +805,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> { async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("storage_mark_played", { userId, itemId }); return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
}, },
/**
* Set the watched flag locally for an item **and everything inside it**.
*
* This backs the watched toggle, and is deliberately separate from
* [`storage_mark_played`] which reports a single track/episode finishing and
* increments `play_count` because the toggle has two directions and applies
* to containers.
*
* The recursion is what makes the toggle honest offline. Jellyfin applies
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
* online the server fixes up the children on the next read; with no server to
* ask, marking a season watched would otherwise tick the season and leave every
* episode inside it unwatched. Targets are drawn from `items` by the same link
* columns the rest of the offline layer uses, so an id that is not cached
* selects nothing and the statement is a no-op rather than a foreign-key error.
*
* Un-marking clears the resume position too, matching the server, so an item
* un-marked offline does not come back offering to resume from a position it is
* no longer meant to have.
*
* `pending_sync = 1` hands the rows to the sync drain.
*
* TRACES: UR-073 | DR-158
*/
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
},
/** /**
* Get playback progress for an item * Get playback progress for an item
*/ */
@@ -779,10 +865,23 @@ async downloadItemAndStart(request: DownloadItemAndStartRequest) : Promise<numbe
return await TAURI_INVOKE("download_item_and_start", { request }); return await TAURI_INVOKE("download_item_and_start", { request });
}, },
/** /**
* Queue an entire album for download * Queue an entire album for download.
*
* Owns the whole operation: the album's track list comes from the server (the
* only place that knows all of it), every track is queued and linked to its
* album, each row's stream URL is resolved here, and the queue is pumped.
*
* The frontend used to do the second half resolve one URL per track and pair
* it with the returned ids **by position**. That pairing had no basis: the ids
* came back in the backend's own order over a different set of rows, so
* whenever the two lists disagreed a row was handed another track's URL, and
* any track past the end of the shorter list was never started at all. Nothing
* crosses the boundary now except the album id.
*
* TRACES: UR-018, UR-055 | DR-173 | UT-170
*/ */
async downloadAlbum(albumId: string, userId: string, basePath: string) : Promise<number[]> { async downloadAlbum(handle: string, albumId: string, userId: string, basePath: string) : Promise<number[]> {
return await TAURI_INVOKE("download_album", { albumId, userId, basePath }); return await TAURI_INVOKE("download_album", { handle, albumId, userId, basePath });
}, },
/** /**
* Queue a video item (movie or episode) for download with quality preset * Queue a video item (movie or episode) for download with quality preset
@@ -809,13 +908,32 @@ async getDownloads(userId: string, statusFilter: string[] | null) : Promise<Down
return await TAURI_INVOKE("get_downloads", { userId, statusFilter }); return await TAURI_INVOKE("get_downloads", { userId, statusFilter });
}, },
/** /**
* Pause a download * Pause a download.
*
* Writing `status = 'paused'` is only half of it, and used to be all of it: the
* streaming task knew nothing about the row and kept running, then overwrote it
* with `completed`/`failed` when it finished. The row flicked to "paused" and
* undid itself the reported "pause does not work". Signalling the worker is
* what actually stops the bytes; it leaves the `.part` file in place so
* [`resume_download`] can continue from it.
*
* A queued (not yet started) download has no worker to signal, and the status
* write alone is enough the pump skips anything that is not `pending`.
*
* TRACES: UR-055 | DR-168
*/ */
async pauseDownload(downloadId: number) : Promise<null> { async pauseDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("pause_download", { downloadId }); return await TAURI_INVOKE("pause_download", { downloadId });
}, },
/** /**
* Resume a paused download * Resume a paused download.
*
* Flipping the row back to `pending` is likewise not enough on its own: the
* pump is not a poller, it runs when something calls it, so a resumed download
* sat untouched until some unrelated event happened to pump the queue. That is
* the other half of "resume does not work".
*
* TRACES: UR-055 | DR-168
*/ */
async resumeDownload(downloadId: number) : Promise<null> { async resumeDownload(downloadId: number) : Promise<null> {
return await TAURI_INVOKE("resume_download", { downloadId }); return await TAURI_INVOKE("resume_download", { downloadId });
@@ -1328,13 +1446,21 @@ async repositoryGetLatestItems(handle: string, parentId: string, limit: number |
return await TAURI_INVOKE("repository_get_latest_items", { handle, parentId, limit }); return await TAURI_INVOKE("repository_get_latest_items", { handle, parentId, limit });
}, },
/** /**
* Get resume items (continue watching/listening) * Get resume items (continue watching/listening).
*
* The home screen's Continue Watching row and every library's "pick up where
* you left off" hero come through here; each item carries its own resume
* position in `UserData`.
*
* TRACES: UR-019, UR-023, UR-034 | IR-024, JA-013, JA-015 | DR-026, DR-038
*/ */
async repositoryGetResumeItems(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> { async repositoryGetResumeItems(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_items", { handle, parentId, limit }); return await TAURI_INVOKE("repository_get_resume_items", { handle, parentId, limit });
}, },
/** /**
* Get next up episodes * Get next up episodes.
*
* TRACES: UR-023, UR-034 | IR-024, JA-014 | DR-026
*/ */
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> { async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit }); return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
@@ -1421,10 +1547,16 @@ async repositoryGetPlaybackInfo(handle: string, itemId: string) : Promise<Playba
return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId }); return await TAURI_INVOKE("repository_get_playback_info", { handle, itemId });
}, },
/** /**
* Get video stream URL with optional seeking support * Get a video stream URL.
*
* There is no start-position parameter on purpose: the URL is an HLS playlist
* covering the whole item, and a position on it makes the server reject every
* segment with `400` (DR-181). Callers resume by seeking after load.
*
* TRACES: UR-004 | DR-181 | UT-182
*/ */
async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> { async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex }); return await TAURI_INVOKE("repository_get_video_stream_url", { handle, itemId, mediaSourceId, audioStreamIndex });
}, },
/** /**
* Get audio stream URL for a track * Get audio stream URL for a track
@@ -1472,6 +1604,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
}, },
/** /**
* Report playback stopped * Report playback stopped
*
* A stop-report that cannot reach the server is queued rather than dropped:
* this is the position the resume point is built from, and losing it is
* exactly the "it forgot where I was" the sync queue exists to prevent. The
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort
* failing the command because the *queue* write failed would tell the caller
* the report was lost when the local position was already saved.
*
* TRACES: UR-025 | DR-154 | UT-151
*/ */
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> { async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs }); return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
@@ -1983,7 +2124,19 @@ export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jell
/** /**
* Library (media collection) * Library (media collection)
*/ */
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null } export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null;
/**
* The favourites scope this library's contents fall under, or `None` for a
* library kind favourites does not carve up (Live TV, channels, books).
*
* Derived here rather than in the UI: which collection type maps to which
* scope is Jellyfin vocabulary, and the frontend must not hold a
* collection-type category table any more than an item-type one. See
* `SearchScope::for_collection_type`.
*
* TRACES: UR-075 | DR-175
*/
favoritesScope?: SearchScope | null }
/** /**
* Live stream information returned from opening a Live TV / channel stream. * Live stream information returned from opening a Live TV / channel stream.
* *
@@ -2121,7 +2274,20 @@ type: string;
/** /**
* Provider-neutral stream classification replaces `stream_type`. * Provider-neutral stream classification replaces `stream_type`.
*/ */
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean } kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean;
/**
* Whether this stream can reach the app as a sidecar it renders itself.
*
* `None` for anything that is not a subtitle the question does not apply,
* and `false` there would read like a verdict. For a subtitle it is the
* difference between a track the app can draw and one only the server could
* have shown, by burning it into the picture (DR-176) which this app never
* asks it to do. The vocabulary of *which formats those are* stays in Rust;
* the frontend only reads the answer.
*
* TRACES: UR-020 | DR-176 | UT-168
*/
supportsExternalDelivery?: boolean | null }
export type MediaType = "audio" | "video" export type MediaType = "audio" | "video"
/** /**
* Lightweight media item for merged playback state * Lightweight media item for merged playback state
@@ -2821,6 +2987,44 @@ export type StreamKind = "audio" | "video" | "subtitle" |
* Any stream kind we do not model explicitly (e.g. embedded image, data). * Any stream kind we do not model explicitly (e.g. embedded image, data).
*/ */
"other" "other"
/**
* Response for a mid-playback streaming-quality change.
*
* Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
* has to reload anything, so no strategy branch lives in the UI.
*
* TRACES: UR-074 | DR-162
*/
export type StreamQualityResponse =
/**
* The native backend was reloaded here; nothing left for the frontend.
*/
{ strategy: "native"; position: number } |
/**
* HTML5 must reload its element with this URL.
*/
{ strategy: "reloadStream"; new_url: string; position: number }
/**
* A ceiling on how much bandwidth a *video* stream may consume.
*
* A quality step is a bundle of concrete transcode parameters total stream
* ceiling, the audio share of it, and the resolution that ceiling can carry
* not just a label. Those numbers are Jellyfin encoding domain vocabulary, so
* they live here and the frontend only ever names a variant; the labels the
* picker shows are served over IPC by `player_get_streaming_qualities`.
*
* The ladder is deliberately expressed in bandwidth rather than resolution: it
* exists to fit a connection, and the resolution cap is chosen *from* the
* bitrate so the encoder does not spend a small budget on pixels it cannot
* afford. See docs/specs/streaming-bitrate-cap.md.
*
* TRACES: UR-074 | DR-162
*/
export type StreamingQuality =
/**
* No client-imposed cap the server may direct-play the source as-is.
*/
"original" | "mbps20" | "mbps10" | "mbps8" | "mbps4" | "mbps2" | "mbps1" | "kbps720"
/** /**
* Represents a subtitle track * Represents a subtitle track
* *
@@ -2942,7 +3146,16 @@ autoPlayCountdownSeconds: number;
/** /**
* Maximum number of episodes to auto-play consecutively (0 = unlimited) * Maximum number of episodes to auto-play consecutively (0 = unlimited)
*/ */
autoPlayMaxEpisodes?: number } autoPlayMaxEpisodes?: number;
/**
* Bandwidth ceiling applied to every video stream.
*
* `#[serde(default)]` so settings JSON persisted before this field existed
* loads as the previous behaviour (uncapped).
*
* TRACES: UR-074 | DR-162
*/
streamingQuality?: StreamingQuality }
/** /**
* Volume normalization levels matching Spotify's presets * Volume normalization levels matching Spotify's presets
*/ */
+7 -4
View File
@@ -409,23 +409,26 @@ describe("RepositoryClient", () => {
handle: "test-handle-123", handle: "test-handle-123",
itemId: "item123", itemId: "item123",
mediaSourceId: null, mediaSourceId: null,
startTimeSeconds: null,
audioStreamIndex: null, audioStreamIndex: null,
}); });
}); });
/**
* There is no start-position argument: a position on the HLS playlist makes
* the server reject every segment behind it with 400, so resume and seek are
* performed by seeking the player after load (DR-181).
*/
it("should get video stream URL with options", async () => { it("should get video stream URL with options", async () => {
const mockUrl = "https://server.com/Videos/item123/stream.mp4?start=300&api_key=token"; const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
(invoke as any).mockResolvedValueOnce(mockUrl); (invoke as any).mockResolvedValueOnce(mockUrl);
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0); const url = await client.getVideoStreamUrl("item123", "source456", 0);
expect(url).toBe(mockUrl); expect(url).toBe(mockUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", { expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
handle: "test-handle-123", handle: "test-handle-123",
itemId: "item123", itemId: "item123",
mediaSourceId: "source456", mediaSourceId: "source456",
startTimeSeconds: 300,
audioStreamIndex: 0, audioStreamIndex: 0,
}); });
}); });
+10 -2
View File
@@ -213,17 +213,25 @@ export class RepositoryClient {
return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId); return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
} }
/**
* A video stream URL, which always begins at the **start of the item**.
*
* There is deliberately no position parameter: the URL is an HLS playlist, and
* a start position on it makes Jellyfin reject every segment behind it with
* `400` (DR-181). Resume and transcoded seeking are performed by seeking the
* player once the stream has loaded.
*
* TRACES: UR-004 | DR-181 | UT-182
*/
async getVideoStreamUrl( async getVideoStreamUrl(
itemId: string, itemId: string,
mediaSourceId?: string, mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number audioStreamIndex?: number
): Promise<string> { ): Promise<string> {
return commands.repositoryGetVideoStreamUrl( return commands.repositoryGetVideoStreamUrl(
this.ensureHandle(), this.ensureHandle(),
itemId, itemId,
mediaSourceId ?? null, mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null audioStreamIndex ?? null
); );
} }
+19 -1
View File
@@ -11,6 +11,13 @@
maxHeight?: number; maxHeight?: number;
class?: string; class?: string;
alt?: string; alt?: string;
/**
* Called once the bitmap is decoded, with its intrinsic pixel size. Lets a
* layout that sizes boxes from artwork (the mosaic) use the shape the image
* actually has rather than the one its item type suggests.
* TRACES: UR-075 | DR-174
*/
onNaturalSize?: (width: number, height: number) => void;
} }
let { let {
@@ -21,6 +28,7 @@
maxHeight, maxHeight,
class: className = "", class: className = "",
alt = "", alt = "",
onNaturalSize,
}: Props = $props(); }: Props = $props();
let imageUrl = $state<string | null>(null); let imageUrl = $state<string | null>(null);
@@ -86,5 +94,15 @@
</svg> </svg>
</div> </div>
{:else} {:else}
<img src={imageUrl} {alt} class={className} /> <img
src={imageUrl}
{alt}
class={className}
onload={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
}
}}
/>
{/if} {/if}
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
interface Props { interface Props {
@@ -83,28 +82,14 @@
} }
} }
} else { } else {
// Download the album: queue all tracks, then start each one // Download the album. One call: the backend lists the album's tracks
// from the server, queues every one of them, resolves each stream URL
// and pumps the queue. This page's `tracks` are what the user is
// looking at, not the download list — pairing them against the returned
// ids by position is what used to leave most of an album unqueued.
const repo = auth.getRepository(); const repo = auth.getRepository();
const basePath = `albums/${albumId}`; const basePath = `albums/${albumId}`;
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath); await downloads.downloadAlbum(repo.getHandle(), albumId, userId, basePath);
// Get target directory for downloads
const targetDir = await commands.storageGetPath();
// Enqueue each track with its resolved stream URL. The backend queue
// pump starts up to max_concurrent at a time and advances through the
// rest automatically as slots free up — so we never hit (and silently
// drop) the concurrency limit the way startDownload did.
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
}
} catch (e) {
console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
}
}
// Refresh to get updated statuses // Refresh to get updated statuses
await downloads.refresh(userId); await downloads.refresh(userId);
@@ -13,6 +13,7 @@
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte"; import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import VideoDownloadButton from "./VideoDownloadButton.svelte"; import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CastSection from "./CastSection.svelte"; import CastSection from "./CastSection.svelte";
import GenreTags from "./GenreTags.svelte"; import GenreTags from "./GenreTags.svelte";
import RelatedItemsSection from "./RelatedItemsSection.svelte"; import RelatedItemsSection from "./RelatedItemsSection.svelte";
@@ -250,6 +251,12 @@
episodeNumber={episode.indexNumber ?? undefined} episodeNumber={episode.indexNumber ?? undefined}
size="lg" size="lg"
/> />
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="lg"
/>
<FavoriteButton <FavoriteButton
itemId={episode.id} itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)} isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
+20 -1
View File
@@ -5,6 +5,7 @@
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import VideoDownloadButton from "./VideoDownloadButton.svelte"; import VideoDownloadButton from "./VideoDownloadButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
@@ -17,9 +18,17 @@
*/ */
current?: boolean; current?: boolean;
onclick?: () => void; onclick?: () => void;
/** Fired when the watched toggle changes, so the series page can reload. */
onWatchedChanged?: () => void;
} }
let { episode, focused = false, current = false, onclick }: Props = $props(); let {
episode,
focused = false,
current = false,
onclick,
onWatchedChanged,
}: Props = $props();
let buttonRef: HTMLButtonElement | null = null; let buttonRef: HTMLButtonElement | null = null;
@@ -177,6 +186,16 @@
{duration} {duration}
</span> </span>
{/if} {/if}
<!-- Watched toggle - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none">
<WatchedToggleButton
itemId={episode.id}
watched={episode.userData?.isPlayed ?? false}
scope="episode"
size="sm"
onChanged={onWatchedChanged}
/>
</div>
<!-- Download button - stop propagation to prevent episode play --> <!-- Download button - stop propagation to prevent episode play -->
<div onclick={(e) => e.stopPropagation()} role="none"> <div onclick={(e) => e.stopPropagation()} role="none">
<VideoDownloadButton <VideoDownloadButton
@@ -1,4 +1,4 @@
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 --> <!-- TRACES: UR-029, UR-037, UR-051 | DR-042, DR-069, DR-070 -->
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import MediaCard from "./MediaCard.svelte"; import MediaCard from "./MediaCard.svelte";
+16 -2
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-051, UR-052, UR-068 | DR-068, DR-078, DR-119 --> <!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle"; import { truncateMiddle } from "$lib/utils/truncateMiddle";
@@ -45,9 +45,16 @@
* TRACES: UR-068 | DR-119 * TRACES: UR-068 | DR-119
*/ */
showFavorite?: boolean; showFavorite?: boolean;
/**
* Force the artwork box to a fixed aspect ratio instead of deriving one from
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
* strip, where square music art next to 16:9 video art would otherwise give
* the cards different heights). Artwork still fills the box via object-cover.
*/
aspect?: "square" | "video" | "poster";
} }
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props(); let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
// Long-press detection. We arm a timer on pointerdown; if it fires before the // Long-press detection. We arm a timer on pointerdown; if it fires before the
// pointer is released (or moves too far), we treat it as a long press and set a // pointer is released (or moves too far), we treat it as a long press and set a
@@ -179,7 +186,14 @@
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist") "kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
); );
const FIXED_ASPECT = {
square: "aspect-square",
video: "aspect-video",
poster: "aspect-[2/3]",
} as const;
const aspectRatio = $derived(() => { const aspectRatio = $derived(() => {
if (aspect) return FIXED_ASPECT[aspect];
if ("kind" in item) { if ("kind" in item) {
return isMusicType ? "aspect-square" : "aspect-[2/3]"; return isMusicType ? "aspect-square" : "aspect-[2/3]";
} }
@@ -0,0 +1,97 @@
<!--
Justified mosaic of tiles: rows of a shared height, each tile as wide as its
own aspect ratio says it should be.
The geometry is `mosaic.ts` (pure, unit-tested); this component supplies the
two things only the DOM knows — how wide the container is, and what shape the
artwork turned out to be — and renders whatever the caller's `tile` snippet
draws.
Measured ratios are committed in one batch rather than per image: artwork
arrives over a few hundred milliseconds, and re-packing on each arrival would
shuffle the grid under the viewer's cursor several times over.
TRACES: UR-075 | DR-174
-->
<script lang="ts" generics="T extends { key: string; ratio: number }">
import type { Snippet } from "svelte";
import { onDestroy } from "svelte";
import {
layoutMosaic,
layoutMosaicStrip,
mosaicTargetHeight,
type MosaicTile,
} from "./mosaic";
interface Props {
items: T[];
/** Row height. Defaults to one suited to the container's width. */
targetHeight?: number;
gap?: number;
/**
* "rows" wraps into justified rows and fills the container.
* "strip" keeps one row at a fixed height and scrolls sideways — the same
* no-distortion rule applied to a shelf.
*/
layout?: "rows" | "strip";
tile: Snippet<[MosaicTile<T> & { reportRatio: (ratio: number) => void }]>;
}
let { items, targetHeight, gap = 8, layout = "rows", tile }: Props = $props();
let containerWidth = $state(0);
let measured = $state<Record<string, number>>({});
let pending: Record<string, number> = {};
let commitTimer: ReturnType<typeof setTimeout> | null = null;
const COMMIT_DELAY_MS = 120;
/** Below this, a measured ratio isn't worth a re-pack. */
const RATIO_EPSILON = 0.02;
function reportRatio(key: string, ratio: number) {
if (!Number.isFinite(ratio) || ratio <= 0) return;
const known = measured[key] ?? items.find((i) => i.key === key)?.ratio;
if (known !== undefined && Math.abs(known - ratio) / known < RATIO_EPSILON) return;
pending[key] = ratio;
if (commitTimer !== null) return;
commitTimer = setTimeout(() => {
commitTimer = null;
measured = { ...measured, ...pending };
pending = {};
}, COMMIT_DELAY_MS);
}
onDestroy(() => {
if (commitTimer !== null) clearTimeout(commitTimer);
});
const height = $derived(targetHeight ?? mosaicTargetHeight(containerWidth));
const sized = $derived(items.map((item) => ({ ...item, ratio: measured[item.key] ?? item.ratio })));
const rows = $derived(
layout === "strip"
? [{ height, tiles: layoutMosaicStrip(sized, height) }]
: layoutMosaic(sized, { containerWidth, targetHeight: height, gap }),
);
</script>
{#if layout === "strip"}
<!-- A strip is measured by the viewport it scrolls in, not by its content. -->
<div bind:clientWidth={containerWidth} class="overflow-x-auto pb-2">
<div class="flex w-max items-start" style="gap: {gap}px;">
{#each rows[0].tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
</div>
{:else}
<div bind:clientWidth={containerWidth} class="flex flex-col" style="gap: {gap}px;">
{#each rows as row, i (i)}
<div class="flex" style="gap: {gap}px;">
{#each row.tiles as placed (placed.key)}
{@render tile({ ...placed, reportRatio: (r: number) => reportRatio(placed.key, r) })}
{/each}
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,90 @@
<!--
One tile of a mosaic: artwork at an exact pixel box, with its label written
over the bottom of the image rather than beneath it.
The label lives on the artwork on purpose — a caption below would add height
outside the box the layout computed, and a row whose captions wrap to two
lines would no longer line up with its neighbours. Keeping everything inside
the box is what lets `layoutMosaic` own the geometry completely.
TRACES: UR-075 | DR-174
-->
<script lang="ts">
import type { Snippet } from "svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props {
label: string;
width: number;
height: number;
/** Item whose Primary image is the artwork. Omit for an icon-only tile. */
itemId?: string;
imageTag?: string | null;
/** Drawn instead of artwork — favourites tiles have no image of their own. */
icon?: Snippet;
/** Tints an icon-only tile so it reads as a destination, not a broken image. */
accent?: boolean;
onclick?: () => void;
/**
* Reports the artwork's true aspect ratio once decoded, so the grid can
* re-pack against the shape the image actually has.
*/
onRatio?: (ratio: number) => void;
}
let {
label,
width,
height,
itemId,
imageTag,
icon,
accent = false,
onclick,
onRatio,
}: Props = $props();
// Request an image comfortably larger than the box so a wide tile is not
// upscaled, without refetching every time the container resizes (CachedImage
// keys its fetch on the item, not on this number).
const REQUEST_WIDTH = 480;
</script>
<button
type="button"
{onclick}
aria-label={label}
class="group/tile relative overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md
transition-transform duration-200 hover:z-10 hover:scale-[1.03] hover:shadow-2xl
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-jellyfin)]"
style="width: {width}px; height: {height}px;"
>
{#if icon}
<div
class="absolute inset-0 flex items-center justify-center
{accent
? 'bg-gradient-to-br from-[var(--color-jellyfin)]/40 to-[var(--color-jellyfin)]/5'
: 'bg-[var(--color-surface)]'}"
>
{@render icon()}
</div>
{:else if itemId}
<CachedImage
{itemId}
imageType="Primary"
tag={imageTag}
maxWidth={REQUEST_WIDTH}
alt={label}
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover/tile:scale-105"
onNaturalSize={(w, h) => onRatio?.(w / h)}
/>
{/if}
<!-- Legibility wash: only as tall as the caption needs, so artwork stays
artwork. -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/45 to-transparent pt-6 pb-2 px-2.5">
<p class="truncate text-left text-sm font-semibold text-white drop-shadow group-hover/tile:text-[var(--color-jellyfin)] transition-colors">
{label}
</p>
</div>
</button>
@@ -4,6 +4,7 @@
import EpisodeRow from "./EpisodeRow.svelte"; import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte"; import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte"; import ClearHistoryButton from "./ClearHistoryButton.svelte";
import WatchedToggleButton from "./WatchedToggleButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation"; import { seasonAnchorId } from "./seriesNavigation";
@@ -65,18 +66,26 @@
/> />
</div> </div>
<!-- Season info --> <!-- Season info.
The header stacks on narrow screens and only shares a row from `sm` up.
Three action buttons and a season title cannot both fit across a phone,
and side-by-side they ended up overlapping. -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<!-- The whole title block toggles the season open/closed. --> <!-- The whole title block toggles the season open/closed. -->
<button <button
type="button" type="button"
onclick={onToggle} onclick={onToggle}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="{anchor}-episodes" aria-controls="{anchor}-episodes"
class="flex-1 min-w-0 text-left group/season" class="min-w-0 sm:flex-1 text-left group/season"
> >
<h2 class="text-xl font-bold text-white flex items-center gap-2"> <!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
a flex item will not shrink below its content width without it, so
a long season name grew the row instead of ellipsising and ran
under the buttons. -->
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
<svg <svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200 class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}" group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
@@ -88,7 +97,7 @@
> >
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg> </svg>
<span class="truncate">{seasonName}</span> <span class="truncate min-w-0">{seasonName}</span>
{#if holdsCurrentEpisode} {#if holdsCurrentEpisode}
<span <span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold" class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
@@ -120,8 +129,9 @@
{/if} {/if}
</button> </button>
<!-- Per-season actions --> <!-- Per-season actions. `self-start` keeps them level with the title on
<div class="flex-shrink-0 flex items-center gap-2"> wide rows; on a stacked phone layout they sit under it. -->
<div class="flex-shrink-0 flex items-center gap-2 self-start">
<SeasonDownloadButton <SeasonDownloadButton
seasonId={season.id} seasonId={season.id}
seriesName={season.seriesName || ""} seriesName={season.seriesName || ""}
@@ -130,6 +140,13 @@
{episodeCount} {episodeCount}
size="sm" size="sm"
/> />
<WatchedToggleButton
itemId={season.id}
watched={watchedCount === episodeCount && episodeCount > 0}
scope="season"
size="sm"
onChanged={onHistoryCleared}
/>
<ClearHistoryButton <ClearHistoryButton
itemId={season.id} itemId={season.id}
itemName={seasonName} itemName={seasonName}
@@ -151,6 +168,7 @@
focused={episode.id === focusedEpisodeId} focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId} current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)} onclick={() => onEpisodeClick?.(episode)}
onWatchedChanged={onHistoryCleared}
/> />
{/each} {/each}
</div> </div>
@@ -0,0 +1,140 @@
<!--
Mark an episode, season or series watched — or unwatched again.
The backend already had both halves (`mark_played` / `clear_watch_history`,
both recursive over a container on the server) and the sync queue already
replayed the first; nothing in the UI had ever called them, so the only way to
mark something watched was to sit through it. This is that control.
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
this series", confirms, and needs the server — this is an everyday toggle: no
confirmation, and it works offline by queueing, in both directions.
TRACES: UR-073 | DR-158
-->
<script lang="ts">
import { syncService } from "$lib/services/syncService";
interface Props {
/** Episode, season or series id. */
itemId: string;
/** Current watched state, as the caller knows it. */
watched: boolean;
/** What is being marked, for the tooltip wording. */
scope: "episode" | "season" | "series";
size?: "sm" | "lg";
/** Show a text label beside the icon rather than icon-only. */
showLabel?: boolean;
/** Called after a successful toggle so the caller can reload. */
onChanged?: (watched: boolean) => void;
}
let {
itemId,
watched,
scope,
size = "lg",
showLabel = false,
onChanged,
}: Props = $props();
let busy = $state(false);
// Optimistic state: the caller's `watched` prop only catches up once it has
// reloaded from the repository, which on a season means a round trip. Without
// this the button visibly ignores the first tap.
let optimistic = $state<boolean | null>(null);
const isWatched = $derived(optimistic ?? watched);
// A new item in the same slot (scrolling a virtualised list, switching series)
// must drop the previous item's optimistic state or it shows the wrong tick.
$effect(() => {
itemId;
optimistic = null;
});
const subject = $derived(
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
);
const label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived(
isWatched
? `Mark this ${subject} unwatched`
: scope === "episode"
? "Mark this episode watched"
: `Mark every episode in this ${subject} watched`
);
async function handleClick() {
if (busy) return;
const next = !isWatched;
busy = true;
optimistic = next;
try {
if (next) {
await syncService.queueMarkPlayed(itemId);
} else {
await syncService.queueMarkUnplayed(itemId);
}
onChanged?.(next);
} catch (e) {
// Put the button back where it was — the change did not happen.
optimistic = null;
console.error("Failed to change watched state:", e);
} finally {
busy = false;
}
}
</script>
<button
type="button"
onclick={handleClick}
disabled={busy}
{title}
aria-label={title}
aria-pressed={isWatched}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed
{isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
>
{#if busy}
<div
class="border-2 border-current border-t-transparent rounded-full animate-spin
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else if isWatched}
<!-- Filled check: this one is done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
6.4-6.4L18.4 8.8l-7.8 7.8z"
/>
</svg>
{:else}
<!-- Outline check: available, not yet done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
</svg>
{/if}
{#if showLabel}
<span>{busy ? "Saving…" : label}</span>
{/if}
</button>
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import type { Library } from "$lib/api/types";
import { buildLibraryMosaic, assumedLibraryRatio } from "./libraryMosaic";
function lib(
id: string,
name: string,
collectionType: string,
favoritesScope?: Library["favoritesScope"],
): Library {
return { id, name, collectionType, favoritesScope } as Library;
}
const MOVIES = lib("1", "Movies", "movies", "movies");
const SHOWS = lib("2", "Shows", "tvshows", "tv");
const MUSIC = lib("3", "Music", "music", "music");
const LIVETV = lib("4", "Live TV", "livetv");
describe("buildLibraryMosaic", () => {
it("leads with the cross-library favourites entry", () => {
const entries = buildLibraryMosaic([MOVIES]);
expect(entries[0]).toMatchObject({
kind: "favorites",
scope: "all",
label: "Favourites",
href: "/library/favorites",
});
});
it("puts each library's own favourites tile right after it", () => {
const entries = buildLibraryMosaic([MOVIES, MUSIC]);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Music",
"Favourite Music",
]);
});
it("links a category tile to that category's favourites tab", () => {
const entries = buildLibraryMosaic([SHOWS]);
const tile = entries.find((e) => e.label === "Favourite Shows");
expect(tile).toMatchObject({ kind: "favorites", scope: "tv", href: "/library/favorites?scope=tv" });
});
it("offers a category's favourites once, however many libraries share it", () => {
const entries = buildLibraryMosaic([MOVIES, lib("5", "Kids Films", "movies", "movies")]);
expect(entries.filter((e) => e.kind === "favorites" && e.scope === "movies")).toHaveLength(1);
expect(entries.map((e) => e.label)).toEqual([
"Favourites",
"Movies",
"Favourite Movies",
"Kids Films",
]);
});
it("gives no favourites tile to a library kind favourites do not carve up", () => {
const entries = buildLibraryMosaic([LIVETV]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Live TV"]);
});
it("ignores a scope the favourites page does not offer as a tab", () => {
const odd = lib("6", "Books", "books", "books" as Library["favoritesScope"]);
const entries = buildLibraryMosaic([odd]);
expect(entries.map((e) => e.label)).toEqual(["Favourites", "Books"]);
});
it("keeps every library, and keys tiles uniquely", () => {
const entries = buildLibraryMosaic([MOVIES, SHOWS, MUSIC, LIVETV]);
expect(entries.filter((e) => e.kind === "library")).toHaveLength(4);
expect(new Set(entries.map((e) => e.key)).size).toBe(entries.length);
});
it("has nothing but the favourites entry when there are no libraries", () => {
expect(buildLibraryMosaic([]).map((e) => e.key)).toEqual(["favorites:all"]);
});
it("gives a category tile the shape of the library it follows", () => {
const entries = buildLibraryMosaic([MUSIC, MOVIES]);
const musicFavorites = entries.find((e) => e.label === "Favourite Music")!;
const movieFavorites = entries.find((e) => e.label === "Favourite Movies")!;
expect(musicFavorites.ratio).toBe(assumedLibraryRatio(MUSIC));
expect(movieFavorites.ratio).toBe(assumedLibraryRatio(MOVIES));
});
});
describe("assumedLibraryRatio", () => {
it("assumes a square cover for music and a wide backdrop otherwise", () => {
expect(assumedLibraryRatio(MUSIC)).toBe(1);
expect(assumedLibraryRatio(MOVIES)).toBeCloseTo(16 / 9);
expect(assumedLibraryRatio(LIVETV)).toBeCloseTo(16 / 9);
});
});
@@ -0,0 +1,87 @@
// What the library overview mosaic is made of, and in what order.
//
// Pure: takes the libraries, returns the tiles to draw. No DOM, no stores — so
// the ordering and the de-duplication rules below are unit-testable rather than
// buried in markup.
//
// Note what is NOT decided here: which favourites category a library belongs to.
// That is Jellyfin vocabulary and arrives on the library itself as
// `favoritesScope` (Rust: `SearchScope::for_collection_type`). This file only
// decides what to *call* it and where to put it.
//
// TRACES: UR-075, UR-067 | DR-174, DR-175 | UT-167
import type { Library } from "$lib/api/types";
import {
FAVORITE_SCOPE_LABELS,
asFavoritesScope,
favoritesRouteUrl,
type FavoritesScope,
} from "$lib/utils/favoritesView";
/** Artwork shapes, as the source images generally arrive. A measured image
* overrides these (see MosaicGrid); they are the shape assumed until then. */
const SQUARE = 1;
const WIDE = 16 / 9;
export type LibraryMosaicEntry = {
/** Stable identity for the layout and for `{#each}` keying. */
key: string;
/** Assumed width / height until the artwork reports its own. */
ratio: number;
label: string;
} & (
| { kind: "library"; library: Library }
| { kind: "favorites"; scope: FavoritesScope; href: string }
);
/**
* A music library's artwork is a square cover; everything else is a wide
* backdrop. Presentation, not taxonomy: this is the shape of a picture, and it
* is a starting guess that the decoded image is allowed to overrule.
*/
export function assumedLibraryRatio(lib: Library): number {
return lib.collectionType === "music" ? SQUARE : WIDE;
}
/**
* The mosaic's tiles, in order: the cross-library favourites entry first, then
* each library followed by its own favourites tile.
*
* A category's favourites tile appears **once**, after the first library of that
* category two movie libraries ("Films", "Kids") share one favourites list, so
* a tile each would be two tiles going to the same place.
*/
export function buildLibraryMosaic(libraries: Library[]): LibraryMosaicEntry[] {
const entries: LibraryMosaicEntry[] = [
{
key: "favorites:all",
kind: "favorites",
scope: "all",
href: favoritesRouteUrl("all"),
ratio: WIDE,
label: "Favourites",
},
];
const seenScopes = new Set<FavoritesScope>(["all"]);
for (const lib of libraries) {
const ratio = assumedLibraryRatio(lib);
entries.push({ key: `library:${lib.id}`, kind: "library", library: lib, ratio, label: lib.name });
const scope = asFavoritesScope(lib.favoritesScope);
if (!scope || seenScopes.has(scope)) continue;
seenScopes.add(scope);
entries.push({
key: `favorites:${scope}`,
kind: "favorites",
scope,
href: favoritesRouteUrl(scope),
ratio,
label: `Favourite ${FAVORITE_SCOPE_LABELS[scope]}`,
});
}
return entries;
}

Some files were not shown because too many files have changed in this diff Show More