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.
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.
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.
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
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
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.
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>
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>
Rust already reported `use_html5_element: false` on Android, but two frontend
overrides threw that answer away, so ExoPlayer's video path had never actually
run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off).
The flag is a suppressor, never a promoter: off forces HTML5 even where Rust
says native, so an in-progress spike cannot ship as the default, but it can
never select native where Rust reported HTML5 — Linux cannot composite behind
WebKitGTK, and promoting there would be a black screen.
Two blockers the spec did not anticipate, both in code assumed to be merely
unreachable rather than broken:
- `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was
always null and `autoAttachSurface()` bailed. The SurfaceView was created and
wired to ExoPlayer but never added to the view hierarchy — video would have
decoded to a surface that was never on screen, whatever the webview did.
This also revives PiP on the video path, which gated on the same flag.
- `createAdapter()` was not the real gate; it is never called in production.
The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped
the native backend `player_play_item` had just started. Both sites now route
through `createAdapter()`.
Compositing needs two independent opaque layers cleared, not one. Clearing only
the page leaves the WebView widget opaque — audio over a black picture, exactly
the symptom the old INTERIM comment described. `videoSurface.ts` toggles both:
the widget background and window drawable from Kotlin, the page backgrounds via
a `data-native-video` attribute keyed by app.css. Transparency lives in
`tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the
playback session so the launcher never shows through the rest of the app.
Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the
player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on
rotation. The mini-player transition remains unverified on device.
Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a
second copy of the Rust cfg gate free to drift from it. `player_get_capabilities`
now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates.
Tests: adapter selection covers the full matrix, including the regression guard
that the flag off beats Rust. Written first and confirmed failing (2 of 7) before
the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.
VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".
PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.
Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.
The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.
The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.
No Kotlin change was needed.
Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.
TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.
Offline video playback — four separate defects, each of which alone stopped it:
DR-133 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.
DR-134 The asset protocol was never enabled: no protocol-asset feature and no
assetProtocol config, so convertFileSrc produced URLs nothing answered.
Also silently defeated the cached-thumbnail path, which fails soft to
the server copy and hid it whenever the server was reachable.
DR-137 Tauri's asset protocol 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.
Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
now served by a loopback HTTP server: bounded 4 MiB chunks streamed
from the file handle, every response length-delimited, and a range-less
request answered with one chunk rather than the file. Confined by a
per-session token and to the app data directory, because loopback is
shared between apps on Android.
DR-138 Release builds set usesCleartextTraffic=false, so Android rejected the
request to that server before any I/O. A network-security-config
exempts 127.0.0.1 only; a remote server must still be HTTPS.
Downloads:
DR-135 download_item never records media_type and the reconnect resolver read
that NULL as 'audio', so a movie queued from a media card had its URL
resolved by get_audio_stream_url and completed as an audio-only
transcode. The item's own type now decides.
DR-136 Rows already downloaded that way are requeued on reconnect, since
prevention alone leaves them reading "downloaded" and still unplayable.
Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.
Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.
The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.
A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.
TRACES: UR-040, UR-005 | DR-052, DR-097
The hybrid favourites read went straight to the online repository on a
cache miss and dropped the result on the floor. Every other read path
persists what it fetches, so this one made the favourites page re-query
the server on every visit — and left the offline mirror (DR-114) empty on
a fresh install, since this is the path that fills it.
It now goes through get_favorites_server_only, which saves through on the
way back.
The command had a matching hole: with nothing cached it returned the empty
result, painting "Nothing favourited yet" at a viewer whose favourites
were simply marked on another client. It now asks the repository for a
real answer instead of an empty state it would correct a round trip later.
TRACES: UR-067 | DR-115
A recoverable player error meant "playback is over": the frontend's error
handler stopped the player unconditionally, so a wifi blip killed the
track. Android already decides in its JNI callback, but MpvBackend is
constructed before PlayerController exists, so its event thread has no
controller to ask.
So MPV reports the failure and the frontend echoes it into the new
player_recover_stream command — the same shape as PlaybackEnded ->
player_on_playback_ended, keeping the decision in Rust. The command
re-opens the stream where it stopped, with the existing attempt budget
and backoff, and returns whether it handled it; only a false answer
falls through to the old stop path.
Android now reports the errors it has already declined as
*unrecoverable*, so the echo never asks the same question twice.
TRACES: UR-004, UR-040 | DR-130 | UT-117
Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)
Also fixes three defects found while confirming that:
- items_fts grew by a full duplicate index every catalog pass. INSERT OR
REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
took a fresh rowid and inserted a second entry. Now a real upsert, with
migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
skipping downloaded items, and refusing to run after a partial crawl
because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
results by. Adds them plus people_fts (migration 022). (DR-111)
Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)
Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)
Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)
FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.
Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md
Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.
The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.
Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
else Next Up, else first unwatched, else the premiere. The third rung is
the offline path, where Next Up is always empty. `sort_series_order` puts
specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
container, also zeroes resume). Offline it refuses rather than diverging
state the next sync would undo.
Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
`/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.
Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
become 2, and `/library/shows/genres` stops being the odd one out.
Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, 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:
load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears
it, so the first real end consumes it and the decision is always Stop. The call
that actually decides is the frontend's echo of the resulting PlaybackEnded into
player_on_playback_ended — and that path had no background-audio case at all, so
it started a countdown whose advance is a webview goto() that cannot start audio
while backgrounded.
Both dispatchers now share PlayerController::auto_advance_to_next_episode, so
they cannot drift apart again.
The handoff base offset moves from the BackgroundAudioOffset Tauri state onto
the controller, and the advance clears it: the next episode's stream is built
without StartTimeTicks, so its timeline is already absolute and a stale base
made player_exit_background_audio return old_base + position_in_new_episode.
Unreachable until the advance actually worked.
Tests (red before the fix):
- test_auto_advance_background_audio_episode_advances_in_backend
- test_auto_advance_foreground_video_episode_uses_countdown
- test_advance_to_next_episode_audio_only_clears_handoff_base
Bump to 0.2.9.
Stage 1 of scoped-search-boundary-implementation.md — the query side.
scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.
Rust now owns the taxonomy:
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }
- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
over include_item_types, which stays for the non-search get_items
callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
paths diverge, so online and offline filter identically — the failure
mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
an explicit includeItemTypes list would silently drop People, folders,
and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.
8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.
The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.
Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.
Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".
Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.
On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.
- Carry episode identity (item_type, series_id) through the
background-audio handoff so the backend queue item still knows it's an
episode; is_episode_item now trusts item_type over the media_type
heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
/player/<id>, which is unavailable while the WebView is suspended.
advance_to_next_episode_audio_only drives it entirely in the backend:
fetch the next episode, build its audio-only stream URL, and load it
into the native audio player, preserving episode identity so the
following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
(online delegates to the existing builder, offline errors) so the
controller can reach it without a frontend round-trip.
TRACES: UR-040, UR-023 | DR-052 | JA-032
Adds a 10-band graphic equalizer to AudioSettings (enabled flag +
per-band dB gains, normalised to 10 entries and clamped to range).
Presets return gain curves; the settings page gains EQ UI. libmpv
applies the filter on Linux (Android parity pending). Old persisted
settings without EQ fields load as disabled + flat.
Also includes the requirements/traceability/ux-flows doc updates for
this feature and the home long-press routing (UR-058/DR-087).
TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
Two bugs on the Downloaded browse surface (UR-055/UR-056):
1. Grouping — browsing a downloaded *library* listed individual leaves
(songs, episodes) instead of their containers. The library-level match in
`get_downloaded_items` selected every downloaded item on the server; add a
NOT EXISTS clause so the top level shows only albums/series/movies, with
leaves still reachable by drilling in. Regression tests for music + TV.
2. "Loading your downloads…" hung on large libraries. The disk-usage
partiality query did an OR-based self-join over the entire synced catalog
(O(items^2), unindexable). Narrow it to downloaded containers first via a
CTE, and add the missing idx_items_season index (migration 020 + base
schema) — parent_id/album_id/series_id were already indexed.
Also annotate the existing backend tests that cover the IT-016/IT-017
end-to-end offline-listing scenarios with their trace IDs.
Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry),
populated from primary_image_tag at every construction/conversion site.
Regenerated bindings.
Frontend: all catalog + player + merged readers now use imageId. The
NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote
session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the
neutral kind/durationMs/imageId. Types that are genuinely out of scope
(Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag.
Rust 456, frontend 644, check clean.
Replace the flat download list with a Downloaded browse surface that
reuses the online grids/cards/detail pages, filtered to on-device media,
plus a demoted Transfers tab. Add repository browse commands
(getDownloadedLibraries/Items, disk usage) with offline/hybrid
implementations, a downloadedCatalog service, formatBytes helper, and
per-item/device disk-usage labels on cards and grids. Regenerated
bindings.
Also carries the inseparable UR-052 offline-filter hunks in
offline.rs/hybrid.rs.
TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.
TRACES: UR-053 | DR-074
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
mirroring the MPV backend.
Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
track over the runTimeTicks estimate, so pausing no longer clobbers the
slider's max to 0 when runTimeTicks is missing.
Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
discriminator, so a video started via player_play_item (no Jellyfin `type`,
mediaType "video") no longer surfaces in the audio mini player.
Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
distinguishing tails (episode numbers, suffixes) stay visible.
Adds regression tests for the duration and mini-player fixes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- music landing: diverse per-genre album sliders (online counts /
offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
listened to in a while") albums via a new repository method across
online/offline/hybrid repos plus the repository_get_rediscover_albums
command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.
Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
persist the resolved stream URL + target dir on each row (migration
017), and the pump starts up to max_concurrent and drains the rest
automatically as slots free, instead of the frontend silently dropping
items past the concurrency limit. Album/series/season buttons now
enqueue rather than calling start_download directly.
Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
cache+server union via a request-id-tagged search-event, so superseded
queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
The offline/online switch was janky because two independent systems decided
"online" and never communicated:
- ConnectivityMonitor owned is_server_reachable (drove the UI banner) but
learned reachability only from a standalone /System/Info/Public ping loop
and from auth/login calls.
- HybridRepository served all real data by racing cache-vs-server but never
read or wrote reachability.
So the banner reflected a side-channel poller, not the system the user actually
experienced: a successful ping could read "online" while authenticated data
calls 401'd or timed out, and three different timeout regimes (5s ping / 30s
data / 100ms cache race) flapped against each other.
Unify into a single source of truth:
- Extract a cheap, cloneable ConnectivityReporter that owns all reachability
transitions and event emission.
- OnlineRepository reports the outcome of every server request to the reporter,
classified via RepoError: Ok/Authentication/NotFound/Server => reachable
(the server answered), Network => offline candidate, Database/Offline =>
ignored (not a server signal).
- Time-window debounce (OFFLINE_CONFIRM_WINDOW = 5s): flip offline only after
sustained network failure; recover instantly on the first success.
- Demote the ping loop to an offline-only recovery probe (no online polling;
real traffic is the signal when online).
- Frontend: navigator.onLine is now advisory (triggers a recheck instead of
forcing offline); removed the dead markReachable/markUnreachable store methods.
Docs updated (README, 07-connectivity, 03-data-flow, 02-svelte-frontend) to
describe the new model and fix pre-existing drift (HTTP client is 30s timeout +
5s ping, not the documented 10s/base_url).
Tests: 12 connectivity tests (debounce, instant recovery, RepoError
classification through report_outcome). Full suite: 398 Rust + 384 frontend
passing, svelte-check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove unused imports and a dead test helper, and strengthen tests that
held unused bindings/fields so the values are actually exercised:
- drop unused imports (HttpConfig, MediaType, std::io::Write)
- remove unused TestEventEmitter::clear helper
- replace meaningless size_of asserts with real empty-manager checks
- assert on MockTrack.name in sorting tests
- online.rs: on Linux, advertise only WebView-decodable codecs (h264 video;
aac/mp3/opus/vorbis/flac audio) in PlaybackInfo so Jellyfin transcodes
HEVC/AV1/VP9/etc. to h264 HLS for the WebKitGTK <video> element.
- player: on Linux, don't load video into MPV (no embedded window — it would
start a redundant decode the frontend immediately stops). Add
PlayerController::set_current_item to keep queue/UI/remote-transfer state in
sync without loading the item into the playback backend.
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
in lib.rs (exports bindings.ts in debug builds).
Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
download_video now take a single request struct (params bundled, body unchanged
via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
now serialize PascalCase to the frontend).
cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
- Move commands/download.rs to commands/download/mod.rs.
- Extract pin/unpin/is_pinned into download/pinning.rs.
- Extract smart-cache stats/config + album recommendation commands into
download/smart_cache.rs.
- Re-exported via pub use so command names stay at commands::download::*;
invoke_handler unchanged, all tests pass.
- commands/player/timers.rs: sleep-timer + autoplay commands (8).
- commands/player/session.rs: media session get/dismiss commands (2).
- Make create_media_item and get_player_status pub(super) so the submodules can
reuse them. mod.rs shrinks from ~2720 to ~2229 lines; invoke_handler unchanged.