TRACES: | DR-204
484 ungated `console.*` calls across 63 non-test frontend files shipped to
end users with no way to turn them off. Mechanical substitution, no control
flow, error handling or message semantics changed:
console.log / console.debug -> log.debug
console.info -> log.info
console.warn -> log.warn
console.error -> log.error
Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope
now carries them; scope names that already existed are preserved verbatim
(`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename
where a file had none. `src/routes/player/[id]/+page.svelte` keeps its
`NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than
flattening them into the page scope.
`grep -rn 'console\.' src/` now matches nothing outside the tests and the
facade itself.
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.
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
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.
Offline video never started: the <video> element reported NETWORK_NO_SOURCE
one millisecond after loadstart, which the UI mislabelled as "may need
transcoding" even though nothing had been fetched. Two independent causes,
both required for playback.
The path was doubled. `downloads.file_path` is stored relative to the storage
root while a download is queued, but the worker rewrites it to the absolute
path it actually wrote once the transfer completes — so a completed row is
already rooted. The player's offline branch rooted it a second time, producing
/data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it
resolves the same column through Rust's resolve_local_media_path, which does
not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC)
so rows written before completion still resolve.
The asset protocol was never enabled. 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 even a correct path
resolved to nothing. This also silently defeated the cached-thumbnail path in
imageCache, which fails soft to the server copy and so hid the breakage
whenever the server was reachable. Scoped to $APPDATA/** — the storage root
holding the database, downloads/ and the thumbnail cache — rather than an
unrestricted grant.
Diagnosed from logcat on device; UT-124 reproduces the doubled path.
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.
Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. 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 Rust
controller still reported that item as its loaded media. Re-entering the
route therefore 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.
Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".
- reportSkippedEpisode marks the outgoing episode played instead of
reporting a stop position, and arms a one-shot suppression consumed by
the player's stop handler, so VideoPlayer's post-navigation unmount
stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
in-progress episode whose series has a next-up entry strictly later in
series order (season, then episode) is hidden from the Home and TV
rows. Movies, series without a next-up entry, and items with unknown
or mixed ordering are always kept.
Adds UR-059, DR-088, DR-089.
TRACES: UR-059 | DR-088, DR-089
Add StreamKind enum (audio/video/subtitle/other) to the domain module with
a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field
(dual-carry), populated at the mapping seam. Frontend VideoPlayer track/
subtitle selection and the channel-video check now use stream.kind instead
of the Jellyfin stream.type string.
Rust 456 (+ stream_kinds_map test), frontend 644, check clean.
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.
Migrate catalog MediaItem consumers from stringly item.type ("Audio",
"MusicAlbum", …) to the neutral item.kind enum across all classification
logic: home, library detail, player routing, artist/person/related/genre
components, tv store.
Model refinements found during migration (each a real distinction the
flat item_type collapsed):
- MediaKind::LiveChannel — live TV (playable, non-seekable) vs
- MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs
- MediaKind::Channel — channel container (drill-in).
TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem.
RelatedItemsSection and GenreTags props migrated from Jellyfin type
strings to MediaKind; MediaKind re-exported from api/types.
Deferred by design: display {item.type} text, ResultsCounter labels,
Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math
(coupled to playbackPositionTicks — phase 3). Old fields still dual-carried
so nothing breaks.
Rust 456 + 7 domain tests, frontend 644 tests, check clean.
Establish a decoupled player boundary so UI and backend interact with video
through one contract, with the HTML5 (Linux/interim-Android) and native
(ExoPlayer) providers as interchangeable primitive-executor adapters.
- PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the
adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource,
play/pause, setVolume, selectSubtitle); it never branches on strategy.
- Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track
return a strategy); the facade dispatches the chosen primitive to the active
adapter. Both providers share the one decision path — logic lives once, in Rust.
- Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets
backend control (lockscreen/remote/sleep) drive the webview <video> element.
- Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause
silently no-opping when the element was re-bound).
- Do not emit a "stopped" player state on natural end-of-video: it flipped the
player/mode to idle mid-handoff and suppressed next-episode auto-advance under
a sleep timer. Jellyfin progress reporting is preserved; the backend's
on_video_playback_ended owns the transition.
- VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter).
- Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416).
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
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.
- Register repository_get_subtitle_url and repository_get_video_download_url
in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
- VideoDownloadButton passed an un-awaited Promise as the stream URL.
- setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
enforce what they checked.
svelte-check: 0 errors. vitest: green. cargo test --lib: green.