Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer
decodes subtitles and delivers them to a listener; it draws none itself. A
PlayerView would supply the view that does, but native video here is a bare
TextureView the WebView composites over — so nothing held the cues and every
one was decoded, delivered and dropped. There was no onCues, no TextOutput and
no SubtitleView anywhere in the app, and media3-ui was not even a dependency.
The gap was invisible for as long as every subtitle URL 404ed: with no text
track to select there was never a cue to lose, so fixing the URL is what
exposed it.
media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of
the content view — above the video, still below the WebView, so cues sit over
the picture and under the app's own controls. It is fitted to the letterboxed
video rect rather than the screen, so cues stay inside the picture and follow
it on rotation, and is removed by the same teardown that detaches the surface
(the defect DR-184 exists to prevent).
Verified on a device: track selected with no "Invalid subtitle track index",
SubtitleView attached at the fitted rect per the live view hierarchy, and cues
legible on screen during playback.
TRACES: UR-020, UR-003 | DR-260
Two faults, both present since v0.0.1, both found and confirmed on a device.
Audio track (DR-258). Jellyfin builds a transcode around one AudioStreamIndex,
so the alternate tracks are not in the stream that arrives — but the native
path only ever called setAudioTrack(n), which indexes ExoPlayer's audio track
*groups*. On Android that is the common case, since any source whose default
audio codec the device cannot decode is transcoded: logcat showed ExoPlayer
holding `Audio tracks: 1` while the menu listed every track in the file, so
each selection warned `Invalid audio track index` and was dropped, leaving the
default track playing with nothing in the UI saying so.
determine_audio_track_switch_strategy now decides by whether the stream in
front of the engine carries the track at all — a direct play still selects in
place, a transcode is re-negotiated at the chosen index and resumed. Where it
resumes is the player's answer rather than the UI's: the native path has no
<video> element to read, so it sends no position, and defaulting that to zero
re-opened the film at the beginning (caught on device before it shipped).
Subtitles (DR-259). The URL was missing its `Stream.` route segment, so every
sideloaded subtitle 404ed; since media3 1.5 a sideloaded text track only
becomes a track group once its file is parsed, so 42 failed fetches left
ExoPlayer with no text tracks and selection warned `available: 0`. Verified
against a live server: the built URL answers 404, the corrected one 200. The
tests that should have caught this asserted the shape of a mock helper that
restated the format string instead of the URL the app requests — so the new
test drives the repository itself, and failed red on the old URL.
A Jellypod podcast listed its episodes alphabetically. The store pinned
SortBy=SortName onto every drill-down, which overrode the order the
channel plugin returns — and since Jellypod prefixes played episodes with
"[Played]", the name sort also clumped every heard episode at the top.
Which order a container's children take is domain knowledge, so it moves
to Rust: the caller names the container (GetItemsOptions.parentKind) and
default_listing_sort answers with the sort. A channel folder is
PremiereDate descending, every other container keeps SortName ascending,
and a caller naming no container still gets no SortBy, so the paths that
rely on the server's own order keep it. An explicit sort always wins.
ChannelFolderItem with is_folder now maps to MediaKind::ChannelFolder
instead of collapsing into Folder — while both were Folder there was
nothing to key the rule on. The offline leg of the cache/server race
applies the same order, so the cached list no longer flashes in name
order before the server's arrives.
TRACES: UR-007 | DR-257 | UT-229, UT-230, UT-231
Two defects in the video control bar, reported together because they present
together: the menus cover each other, and in portrait they cover the edge of
the screen instead of the video.
DR-256 (a) — audio track, quality and subtitles each owned a `show…` boolean
and no toggle cleared the others. Opening a second menu stacked it over the
first in the same corner: the newer panel hid rows of the older, both stayed
live, and both kept taking clicks. A single `openMenu` value replaces the three
booleans, which makes "at most one menu is open" a property of the state rather
than something every handler has to remember to enforce. The desktop volume
popup was a fourth uncoordinated menu in the same row, so `VolumeControl` grew
optional controlled-open props and joined the group; without them it still
manages itself, which is how MiniPlayer and AudioPlayer keep it.
DR-256 (b) — every panel was `absolute right-0` against *its own icon button*.
Those icons sit mid-row, so a 200-220 px panel extended left from a point well
inside the bar and hung off the left edge of a phone in portrait: half the
tracks could not be read, let alone tapped. One shared panel now anchors to the
control ROW's right edge, clamped to `min(20rem, 100vw - 2rem)` wide and
`min(300px, 45vh)` tall. A full-screen dismiss layer inside the controls subtree
closes it on a tap elsewhere — inside, so the tap never reaches the container's
gesture layer and cannot toggle playback (DR-098).
The volume popup had the same placement bug from the other side: `left-full`
opened it rightward from an icon near the right end of every bar it appears in.
It opens upward, right-aligned, now. And the icon row wraps rather than
overflowing — in portrait the transport controls plus nine icons are wider than
the screen, which pushed fullscreen and close past the edge.
The test renders the real component and drives the toggles, because neither
fault is visible from a helper: both are properties of the composition. It was
written first and failed on both counts — `["Audio Track", "Subtitles"]` open at
once, and no shared panel to anchor.
The tag message counted twelve defects while the changelog described ten. Both
of the missing ones are user-visible and belong in the user-facing artifact: a
quality ceiling that outlived the episode it was chosen for, and a seek that
outlived the file it was meant for.
The third review fix — collapsing two identical URL helpers — is not here on
purpose. Nobody using the app can tell.
Verified each against the code before acting; four of the five findings held,
one did not.
DR-253 — a deferred seek outlived its file. `seek` holds a position while MPV
has nothing loaded and `FileLoaded` applies it (DR-241), but neither `load` nor
`stop` discarded it. Scrub near the end of a transcoded item — which re-opens
the stream — then skip to the next item before the reload completes, and the
old position lands on the new item. It starts wherever the previous one was
scrubbed to, silently. Both lifecycle points clear it now.
DR-254 — a per-playback quality ceiling outlived its playback. The override is
process-wide and describes one playback: dropping to 720p for a struggling
episode says nothing about the next. Every advance the frontend drives clears
it through player_play_item, but the background audio-only advance loads the
next episode in Rust and skipped all three clearing sites — so every later
episode stayed capped, with nothing in the UI explaining why.
DR-255 — `playable_url` was a byte-identical copy of `playback_url`, added for
the cross-platform open path. The original is `#[cfg(target_os = "android")]`,
so it does not exist in a Linux build and nothing warned. Two matches over
MediaSource meant a new variant could be handled in one and forgotten in the
other. The gate is gone and the copy with it.
The fifth finding — that the comment on `video_audio_codecs` describes a
renderer switch the code no longer has — does not hold. `get_player_status`
hard-codes Android to Native, but `experimentalNativeVideo` is still live in
VideoPlayer.svelte as a suppressor that can force HTML5 even when Rust says
native. The switch exists, so the narrow codec list is still doing its job.
Both correctness fixes are red-then-green. The tests are wiring assertions in
the style of UT-218: what matters is the call site, and reaching these at
runtime needs a live MPV handle or a repository, a server and a player. That
technique now appears three times and is worth watching — it pins call sites,
not behaviour.
The review's sharpest point is one it raised as redundancy: MpvPlayer already
handles DR-253 correctly, resetting deferred state on every open, and the old
path had to be patched separately. That is the drift two parallel engines
produce, and the argument for finishing DR-248/249 rather than leaving
LegacyPlayer in place indefinitely.
795 Rust tests, 1088 frontend, every CI check green locally.
CI caught this, which is what it is for: `check-doc-links.sh` failed because
media-player-controller.md links to backend-owned-stream-selection.md twice and
the file was not on master.
My fault, and worth recording how. That spec was written and committed directly
on master early on, before the work moved into a worktree. Squashing the branch
began with `git reset --hard` back to the merge base, which dropped those early
master-only commits — and the squash then brought in a document referencing one
of them.
Nothing was lost: the commits are still reachable, and the file is restored from
1d56517f along with its index entries in docs/specs/README.md and the docs site.
The lesson is narrower than "be careful with reset": a squash whose base is
chosen by hand silently drops anything committed outside the branch being
squashed. Only the link checker noticed, because it is the one gate that reads
across files rather than within them — and it is also the one gate I had not
run locally before pushing.
All CI checks now pass locally: boundary, doc links, tooling, format, lint at
the 158 ceiling, traces validate, coverage 90%.
Fair criticism: hardware time went into writing a checklist describing what the
tablet found, when it should have gone into making the suites able to find it.
A checklist decays and depends on someone following it. A test does not.
The gap was specific. Every engine the conformance suite drives reports sane
numbers, so it stayed green while a real one took the backend down. The old
PlayerBackend contract is a plain f64 — it never promised finite, never
promised positive, and nothing enforced it.
UT-223 adds the engine that was missing: a HostileBackend answering with
C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero. Reading a
snapshot must yield no duration and a zero position rather than panicking.
Against the adapter as originally written it fails with
cannot convert float seconds to Duration: value is negative
which is the exact panic that produced a black screen on the tablet — now
reproduced in 0.00s on a laptop instead of by backgrounding an app.
UT-224 pins the other hardware-only finding: stopping clears an active
background-audio handoff, flag and base offset both. That was verified by
listening to a device, which is not a test.
Both were confirmed to fail against the pre-fix code before being kept.
The verification plan now says to prefer moving cases out of it and into tests,
and that what remains should be what genuinely needs eyes, ears or a display —
not what merely has not been automated yet.
793 Rust tests.
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
cargo-deny went red on master with two quick-xml DoS advisories
(RUSTSEC-2026-0194, RUSTSEC-2026-0195).
They were absent before, and correctly so: quick-xml reached the graph
only through plist on Apple targets, and deny.toml scopes the graph to
the targets this project actually ships. The Tauri 2.11 upgrade changed
that. plist is now pulled in by tauri-utils, which is a build-dependency
of tauri-build, so it compiles on every target including Linux and the
advisory became genuinely in scope.
That is the gate behaving as designed -- silent while the crate was
unreachable, loud the moment a dependency upgrade brought it into a build
we ship.
Fixed rather than ignored. plist 1.10.0 requires quick-xml ^0.41.0, which
carries both patches, and tauri-utils accepts plist ^1, so the upgrade is
a lockfile change with nothing else moving:
plist 1.8.0 -> 1.10.0
quick-xml 0.38.4 -> 0.41.0
An ignore entry would have been easy to justify here -- build-time only,
parsing files we generate, absent from every shipped binary -- and that
is exactly why it would have been wrong: the justification would have
outlived the reason for it, and the entry would still be sitting in
deny.toml long after the upgrade became available.
No release. quick-xml is a build dependency, so it is not inside any
v0.10.1 artifact; this only restores master to green.
Verified: cargo deny (advisories, bans, licences, sources all ok),
cargo check, 765 tests, cargo fmt --check, clippy -D warnings.
Locking the screen kept a video's audio playing whether or not the
background-audio button was on. Reported as "audio only mode is always
active even if not selected".
The button (UR-040) was built for the WebView <video> path, where losing
visibility kills the decode: it chose between handing off to a native
audio stream and letting playback stop. Native video then became the
default renderer (DR-188), and on that path playback runs through
ExoPlayer inside a MediaSessionService -- a foreground media service
whose entire purpose is to keep playing while the app is hidden. Nothing
stopped it, and nothing in the codebase paused on background.
So the button governed a handoff that no longer had a gap to bridge.
There was no interruption to paper over, and a user who never touched it
got background playback anyway.
The gating made it self-concealing: MainActivity.onStop only dispatched
'jellytau-background' when backgroundAudioEnabled was already true. The
one notification that the app had gone away was itself conditional on the
setting, so with the button OFF nothing could react even in principle.
onStop and onStart now fire unconditionally and carry the two facts only
the activity knows -- whether the toggle is armed, and whether Android
put the window into picture-in-picture.
What to do about it is decided in Rust (player/background_policy.rs),
because it depends on whether the item has a picture to lose:
video + toggle off -> Pause
video + toggle on -> HandOffToAudio
music, either -> KeepPlaying (no picture to give up)
picture-in-picture -> KeepPlaying (the window is still on screen)
It takes no renderer parameter on purpose. Two renderers with two
behaviours and one toggle reaching only one of them is what produced the
defect; a rule that cannot see the renderer cannot reproduce it.
Two failure modes are deliberate. A decision call that fails leaves
playback alone rather than risking silence mid-listen. An event with no
detail -- older Kotlin against newer JS -- reads as "armed, not PiP",
degrading to the previous behaviour instead of pausing unexpectedly.
Foregrounding resumes only what backgrounding paused: a video the user
paused themselves before locking stays paused.
Written test-first per CLAUDE.md. The stub encoded today's behaviour
(nothing ever pauses) and failed exactly as reported --
`left: KeepPlaying, right: Pause` -- before the rule was implemented.
Verified on a device, R8-minified, both directions:
[player_background_action] video=true armed=false pip=false -> Pause
[player_background_action] video=true armed=true pip=false -> HandOffToAudio
UR-040 / DR-224 / UT-211.
The v0.10.0 release build failed in Build Linux after 16 minutes:
failed to bundle project: xdg-open binary not found
/usr/bin/xdg-open: No such file or directory
linuxdeploy embeds xdg-open into the AppImage and aborts the whole bundle
when it is absent. deb and rpm had already bundled fine; only AppImage
was affected.
This is the one failure tonight that building locally could not have
caught, and the reason is worth writing down: a developer machine is a
desktop and always has xdg-utils, so the AppImage builds there and fails
on a minimal server image. The asymmetry is the bug. Every other release
defect this evening was found by building locally first; this one needed
the runner.
xdg-utils, desktop-file-utils and zsync are added together rather than
one at a time. Each round trip costs an image rebuild plus a failed
release build, and those three are what linuxdeploy commonly reaches for
(xdg-open, desktop-file-validate, and zsync for delta updates).
Workflows move to jellytau-builder:2026.08.1, built and pushed with all
three verified present inside it before this commit.
ci-operations.md gains two things learned here: that an apt addition
invalidates the layer above the cargo-install steps, so it is a ~20 minute
rebuild rather than the ~2 minutes the trailing layer normally gives; and
that Tauri's AppImage bundler downloads linuxdeploy, AppRun and two plugin
scripts from GitHub during the build, so an AppImage build depends on
GitHub being reachable from the runner.
Two defects on the release path, both of which would have failed the
v0.10.0 build after all three platforms had already compiled -- caught by
running a real signed build locally instead of waiting for the tag.
**createUpdaterArtifacts was never set.** Without it Tauri emits only the
plain .AppImage and .exe: no signatures at all. The manifest step then
finds none and aborts by design, so the release dies at Create Release
having spent ~40 minutes building artifacts it cannot publish.
**The manifest looked for the wrong filename.** Tauri v2 signs the
.AppImage *itself* and writes <name>.AppImage.sig beside it. The
.AppImage.tar.gz form this workflow globbed for only exists under
createUpdaterArtifacts: "v1Compatible". A real signed build produced:
154M JellyTau_0.10.0_amd64.AppImage
420 JellyTau_0.10.0_amd64.AppImage.sig
so the glob would have matched nothing and the step would have aborted
for a second, entirely different reason. Both the artifact collection and
the manifest now use the v2 names, and the AppImage and its .sig ship
together -- a manifest referencing a signature that was never uploaded
fails only on the user's machine.
Verified before tagging rather than after: the manifest logic was run
against the real artifacts (420-char minisign signature read correctly)
and the resulting latest.json checked for validity and shape.
The Windows side already used the correct pattern (<installer>.exe.sig),
which is why only Linux needed the change.
Two user-visible features -- the app can update itself, and it can hand
you a redacted diagnostics bundle -- plus the supply-chain, release
integrity and build work behind them.
A minor bump rather than a patch, matching how v0.9.0 was cut off v0.8.2
for a single new user requirement. This one carries two (UR-077,
UR-078), both with UI in Settings.
The CHANGELOG entry is the release body now: build-release.yml publishes
the `## v0.10.0` section and fails if it is missing, instead of the fixed
block of install instructions that every release from v0.0.1 to v0.9.1
carried verbatim.
The plugin versions could not be matched upward without this: both
tauri-plugin-log 2.9.0 and tauri-plugin-updater 2.10.1 require tauri
^2.10, and the tree was on 2.9.5. So the framework moves with them --
tauri 2.9.5 -> 2.11.5, tauri-build 2.5.3 -> 2.6.3, wry 0.53.5 -> 0.55.1
-- and every plugin's Rust crate and npm package is now pinned to the
same version on both sides.
That upgrade broke Android outright, and the breakage is the interesting
part.
Seven call sites in this crate reach JNI through
ndk_context::android_context(), which reads a process-global pair of
pointers. Nothing here ever set that global. `tao` did -- the windowing
layer under wry, three levels below anything this project names in
Cargo.toml. tao 0.34.5 called initialize_android_context() while starting
the activity and our code read what it left behind. tao 0.35.3 keeps the
same two pointers in a private struct and no longer publishes them.
The result, on every launch, was:
PANIC at ndk-context/src/lib.rs:72: android context was not initialized
8: ndk_context::android_context
9: jellytau_lib::run::{{closure}}
Not a crash in our code, and not a change to our code: an undocumented
side effect of a transitive dependency disappeared. Relying on someone
else to populate a global is a dependency that does not appear in
Cargo.toml and gives no warning when it goes.
src-tauri/src/android_context.rs now owns that invariant instead of
assuming it. JNI_OnLoad captures the JavaVM as the shared library loads
-- the earliest moment available, and nothing in tao, wry or tauri
defines one to collide with. The Context is resolved lazily via
ActivityThread.currentApplication() and pinned as a global reference for
the process lifetime, since ndk_context stores a bare pointer and does
not own it. It publishes the Application rather than the Activity:
SecureStorage.initialize() immediately reduces its argument to
applicationContext anyway, and an Application cannot outlive itself the
way a retained Activity would.
Restoring the global keeps all seven callers untouched. Threading a VM
and Context handle through five credential call sites would have been a
larger change with more risk, on the credential path.
Failure now degrades instead of aborting: it is logged and credentials
fall back to the encrypted-file path, which the app already supports.
Verified on a device, R8-minified, not merely compiled:
[INIT] Android JavaVM and Application published to ndk_context
Android SecureStorage initialized successfully
Android Keystore available via SecureStorage
[INIT] Using system keyring for credential storage
[CodecDetection] Detected 7 video codecs: av1,h263,h264,hevc,...
-- the real keystore path, not the fallback, and the app stays up. None
of this is reachable by CI: nothing there runs the app.
Also fixed here, both found the same way:
- `tauri android build --apk true` is now `--apk`. The CLI took a value
until 2.10; from 2.11 the stray `true` is a positional and the build
fails before starting. Three call sites in build-android.sh and one
in build-release.yml -- the latter builds the signed APK, by far the
most-downloaded artifact.
- scripts/build-android.sh ran `npm install` on its clean-build path in
a bun project, ignoring bun.lock and re-resolving the tree. That is
exactly how the plugin crate/package versions drift apart again.
scripts/check-tooling.sh now fails on any npm/yarn/pnpm invocation or
foreign lockfile, and runs in CI.
DR-222, DR-223.
Preparing v0.10.0 meant building the release locally first. It did not
build. Two separate defects were sitting on master, both invisible to
every gate this project has, for the same reason: nothing in
build-and-test.yml runs `tauri build`. Only a tag does. So the first time
anyone would have discovered either was a failed release.
**Tauri plugin versions had drifted apart.** Tauri refuses to build when a
plugin's Rust crate and npm package are on different minor versions:
tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)
tauri-plugin-updater (v2.9.0) : @tauri-apps/plugin-updater (v2.10.1)
Introduced by the updater and diagnostics work in this same branch --
`cargo add` took what the pinned toolchain allowed while `bun add` took
latest, and the caret ranges let them separate. cargo check, clippy,
cargo test and svelte-check all passed.
Matching upward pulled wry 0.53.5 -> 0.54.2 along with wasm-bindgen,
web-sys and webkit2gtk: the webview layer, which on Linux is the video
playback path. That is not a change to make while cutting a release, so
the npm packages are pinned down to the crates instead -- exactly, not by
caret, since the caret is what allowed the drift. The upgrade is worth
doing deliberately, with a playback check, and ci-operations.md says so.
CI now runs `tauri info`, which performs the same comparison without
building. Verified by reintroducing the mismatch and watching it fail.
**The AppImage target had never been built.** It was added earlier in this
branch because the release notes had advertised an AppImage for months
while tauri.conf.json never produced one. It does not work out of the
box: linuxdeploy carries its own `strip`, too old to parse the .relr.dyn
section modern toolchains emit, and it fails on every bundled library --
strip: libzstd.so.1: unknown type [0x13] section `.relr.dyn'
failed to bundle project `failed to run linuxdeploy`
Ubuntu 23.10+ links with -z pack-relative-relocs by default, so the CI
builder image fails exactly as a modern Arch host does. NO_STRIP=true is
linuxdeploy's documented escape hatch. The resulting 153 MB AppImage was
verified to be well-formed and to actually start.
Without this the release would have failed at the Linux build step --
the artifact check added earlier refuses to publish when no AppImage is
produced, which is the behaviour we want, but it would have refused a
tagged build rather than a local one.
Also: the traceability extractor now reads the tooling shell scripts that
carry TRACES comments. DR-207, DR-213 and DR-220 all had them and were
counted as uncovered because only .ts/.svelte/.rs were scanned. Listed
individually rather than globbing scripts/*.sh -- most implement nothing,
and adding one should be a decision.
DR-221.
Two defects found while preparing v0.9.2, both of which had been shipping
for months without anything to notice them by.
**Every release note was the same 1,050 bytes.** All 35 releases from
v0.0.1 to v0.9.1 published identical generic install instructions whose
"What's New" section read "See CHANGELOG.md" -- a link that does not
resolve from a release page. A reader learned nothing about what changed
in any release the project has ever made.
The body now comes from the `## <version>` section of CHANGELOG.md, and a
missing section fails the release: notes that say nothing are worse than
a build that waits for a maintainer to write two sentences. The 35
published bodies have been backfilled from the changelog via the tea CLI.
This also corrects something introduced two commits ago. That change
generated the body from `bun run release:notes`, which CLAUDE.md is
explicit about -- its output is "a reviewed draft, not a final
changelog". Publishing it unreviewed proved the point immediately: the
v0.9.1..HEAD range contains a repo-wide prettier sweep, so every file in
src/ counted as changed, their TRACES resolved to nearly the whole
matrix, and the draft claimed the release had added the entire
application. The script now skips cosmetic commits (chore(format),
chore(deps), style) and reports how many rather than silently returning a
smaller set, but it stays a local drafting tool.
**Every release from v0.1.0 to v0.8.2 shipped every Windows installer
ever built.** src-tauri/target/*/release/bundle/ is not versioned, cargo
never cleans it, and the runner reuses the target directory -- so the
copy step's bundle/**/*-setup.exe glob collected the lot. v0.8.2 carried
sixteen installers, thirteen of them stale; v0.5.0 offered users a
download list going back to 0.1.0. Eight months, and nothing to notice it
by: the upload loop reported success, the files were real, and the page
looked busy rather than wrong. It stopped only because an unrelated cargo
cache change wiped the runner's target dir, so it was dormant, not fixed.
Both desktop builds now remove the bundle directory before building, so a
stale file cannot exist to be copied. Filtering the copy by version would
have hidden it instead. The Linux job gets the same treatment: it was
never hit only because Linux packaging is newer, and the glob is
identical.
scripts/check-release-artifacts.sh is the backstop for whatever
reintroduces one by a route nobody predicted. It runs before the SBOM,
the checksums and the upload -- all of which describe the file set, so a
stale artifact has to be caught before it is hashed and published as part
of the release. Verified against a reconstruction of the real v0.8.2
accumulation.
DR-219, DR-220, UT-210.
Rust becomes the single owner of which stream to play — direct play or
transcode, at what ceiling, over what transport — and hands every player
backend a self-describing StreamSelection instead of a bare URL. mpv,
ExoPlayer and the HTML5/hls.js path all consume one decision rather than
three places re-deriving it.
The motivating leak is concrete. VideoPlayer.svelte determines transport with
`currentStreamUrl.includes(".m3u8")`, in two places, for a URL Rust
constructed and therefore already knows the shape of. That is the boundary
rule in miniature: not item-type taxonomy, but the same error of
reconstructing a domain fact in the presentation layer because the wire shape
did not carry it. A tagged Transport enum deletes it.
The design line, which ExoPlayer forces: Rust decides *what stream*, the
player decides *how to deliver it*. ExoPlayer has genuine adaptive track
selection; this spec must not reimplement or fight it. Rust only adapts where
the player cannot (mpv) and the server actually offers a ladder.
Six phases, and phase 1 stands alone as pure ownership movement with no
behaviour change. Phase 4 (direct-play negotiation) is what removes the
transcode and unblocks the Linux native-video work. Phase 5 (adaptation) is
gated on counting EXT-X-STREAM-INF entries in a real playlist — the
acceptance criteria require that count be recorded before it is either
started or dropped.
Takes DR-121 from read-through-media-cache.md, which specced Rust-owned
quality reporting but never built it; that spec keeps its capture half.
Three corrections to the Linux native-video spike, each of which reverses
something recorded earlier in the same session:
- The crash is unexplained. It was first blamed on hwdec=auto-safe's Vulkan
failures, on a misreading of the logs — those are two per run at start-up,
not per-frame, and every clean run has the same two. A 300s soak on
auto-safe survived. So did 240s of automated fullscreen toggling (~120
transitions) and 240s of continuous resizing (~2000 reallocations). Three
hypotheses, none reproduced. Recorded rather than dismissed: an
intermittent fault nobody can reproduce is worse to inherit than a
deterministic one.
- G5 drops to amber. It looked and felt smooth, but the only SIGSEGV observed
came from the only session in which fullscreen was exercised, and the spike
has no lifecycle handling at all — it never frees the render context. An
implementation must bind that to the GL context's lifetime regardless of
what caused this crash, because Android already paid for that lesson as
DR-184.
- Finding 3's premise is in doubt. "The webview path already has real ABR via
hls.js" was never checked against the URLs this app builds:
get_video_stream_url requests a single rendition, the frontend has no
level-handling code at all, and a quality switch is implemented by
re-opening the stream. If the playlist is single-variant there is no
adaptation to lose. The decisive test needs a live server and is recorded
as unrun.
Also records hardware decode working through the render API (nvdec-copy
engaged), and that hwdec=vaapi silently fell back to software on this box.
Adds the spike write-up and re-opens finding 2 of
playback-backend-unification.md, which concluded that video cannot unify
on a native engine because the webview owns the surface.
That finding's general form has since been falsified on Android, where
native video composites behind a transparent WebView and ships on by
default. The evidence behind it was also entirely about foreign-window
embedding -- mpv's render API, drawing into a GL context we own inside
Tauri's own GTK tree, was never tested. The spike tests that one claim
and comes back green on Linux for both X11 and Wayland, bar the Tauri
default_vbox() half of G1.
Findings 3-6 are deliberately left standing. Finding 3 in particular --
mpv has no adaptive bitrate -- is an independent disqualifier that a
green compositing result does not clear, and the spike says so rather
than reading as a green light.
The next-free-id line moves to UR-079 / IR-033 / DR-219: this branch
allocated UR-077 and UR-078 for the updater and diagnostics work, and
DR-215 through DR-218 with them, after that line was last written.
Authored in a parallel session working in the same checkout; committed
here so it travels with the rest of the branch.
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.
Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.
Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.
Two things the tests caught that review would not have:
- redact_headers recursed on its own output. The replacement keeps the
header NAME, so the next call matched the same header forever; the
test died with a stack overflow. It is a forward scan now.
- The frontend forwarder used `void plugin.error(...)`. `void` discards
a promise's value but not its rejection, so in any webview without
IPC -- a unit test, SSR, a browser preview -- every log line became an
unhandled rejection. 20 of them showed up the first time coverage
ran. Each call now attaches a catch.
Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.
Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.
The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.
Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.
Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
The commit that removed .gitea/workflows/runner-health.yml was amended
with a git add whose pathspec named the already-deleted file, so the add
aborted and only the deletion was staged -- leaving ci-operations.md
still describing a scheduled job that no longer exists.
The runner section now says what to check by hand and why there is no
job: on a single-slot runner a daily job takes the slot and pulls the
builder image to run df, and df inside a container does not reliably
describe the host disk.
The repo had no SECURITY.md, CONTRIBUTING.md, code of conduct, or issue
and PR templates. For a client that handles Jellyfin credentials and
ships signed binaries, the missing one that actually matters is
SECURITY.md: there was no stated way to report a vulnerability
privately, so the only available channel was the public tracker.
CONTRIBUTING.md documents the gates as they now stand, including the
three ratchets and which direction each is allowed to move, and the two
rules that surprise people: bug fixes start with a failing test, and
Jellyfin's taxonomy stays in Rust.
The bug template asks the three playback questions -- streaming or
downloaded, transcoding or direct, music or video -- because those
answers decide which of several very different code paths a report is
about, and reconstructing them over several round trips is most of the
cost of a playback bug report.
docs/build/ci-operations.md is the missing operations manual: how to
change the builder image and in what order (image pushed before the
workflow that names it, or CI breaks), why tags are dated rather than
:latest or per-SHA, what each secret is for, and what losing the updater
private key would mean -- installed desktop clients only accept payloads
signed by the key matching the public key they shipped with, so losing it
means everyone reinstalls by hand.
Disk exhaustion on the runner is documented as a manual check rather than
a scheduled job. A daily job would occupy the only slot on a single-slot
runner and pull the whole builder image to run `df` -- and `df` inside a
container does not reliably describe the host's disk, so it would spend
real build capacity reporting a number that might be wrong. What the doc
records instead is the part that is actually hard to rediscover: the
symptoms (cargo dying mid-link, docker refusing to pull, actions/cache
quietly not saving) and that `docker volume prune` needs `-a` to touch
named volumes, which is how it filled up unnoticed.
Two things in these docs are stated plainly because they are true and
were not written down anywhere: without branch protection every gate in
the pipeline is advisory, and the Gitea instance -- canonical remote,
signing secrets, registry, runner -- is not backed up by anything in this
repository.
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.
Desktop now checks a signed manifest, shows the version and its notes in
Settings, and installs and relaunches on request. The signature check is
the whole point: it is what stops a substituted download from being
installed by the app itself. Windows binaries stay unsigned for
SmartScreen purposes -- that is a code-signing certificate, a separate
problem -- but the update payload is verified against our own key.
Android is deliberately not wired to the updater. An app may not replace
its own APK; that is the package installer's job, and the plugin has no
Android implementation. It gets a link to the releases page instead of a
button that would throw.
The plugins are gated with a target-triple cfg rather than
cfg(desktop). Cargo only evaluates target cfgs in a [target.'cfg(..)']
table, so cfg(desktop) matches nothing, silently drops the dependency,
and fails much later with "Permission updater:default not found" -- which
is exactly what the first attempt here did.
Where the manifest lives took some finding. This Gitea serves
/releases/download/<tag>/<asset> but 404s on
/releases/latest/download/<asset> (verified against a real asset), so
there is no stable latest-release URL. The gitea-pages branch is
force-pushed wholesale by publish-docs.yml, so it cannot host the file
either. latest.json therefore gets its own orphan branch, read over the
raw-file URL, and is published from a scratch repo in RUNNER_TEMP rather
than by switching branches in the checkout -- doing that would have left
the following steps standing on a one-commit history, and the next step
but one runs release:notes against the real commit range.
Also fixed, all of it release-integrity:
- "appimage" is in bundle.targets. The release notes have advertised an
AppImage for months; tauri.conf.json never built one, the artifact
step globbed for *.AppImage, found nothing, and said nothing. The
step now fails instead.
- The .AppImage.tar.gz/.sig pair and the NSIS .sig are collected. A
manifest referencing a signature that was never uploaded fails only
on the user's machine, so the manifest step also refuses to write an
entry with an empty signature.
- Release notes are generated by release:notes from the traceability
graph, which is what CLAUDE.md has asked for all along, instead of a
fixed heredoc that said "see CHANGELOG.md for detailed changes" and
linked "GitHub Issues" on a Gitea-hosted project.
- The notes tell users how to verify a download with SHA256SUMS.
Requirements UR-077 / DR-217, tests UT-208 (12 cases over the version
comparison and the platform decision, including that a pre-release does
not offer itself as an upgrade to the matching release).
Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
The sixteen specs folded in last commit were folded because someone noticed
they had gone stale, not because anything said they should be. Without the rule
written down the directory drifts straight back to a mix of promises and
descriptions, and neither can be trusted: you cannot tell from a file whether it
describes the build or proposes a change to it.
So: docs/specs/ holds only unshipped work, there is no "Implemented" resting
state, and the fold-in and the deletion happen in the same commit.
The template now asks for the destination architecture doc **up front**, which
is a design check rather than bookkeeping — a feature that fits no existing doc
usually has an unclear layer assignment, and it is cheaper to find that out at
spec time. It also tells the author which half of what they are writing is
durable (invariants, rejected alternatives, the defect a decision prevents) and
which half dies with the file (phases, migration steps, acceptance criteria).
The review checklist gains a Lifecycle section, including the case that gets
lost otherwise: out-of-scope work worth doing has to be written where it will
still be found after the spec is gone.
The project shipped signed Android builds and unsigned desktop binaries
with no vulnerability scanning of any kind. Nothing checked the ~500
crate Rust graph or the JS packages against an advisory feed, and nothing
checked that what we redistribute inside an MIT bundle permits it.
The first cargo-deny run found eight vulnerabilities and one
unsoundness -- bytes, four in rustls-webpki, time, two in quick-xml and
rand -- every one of them closed by a `cargo update` nobody had a reason
to run. That update is in this commit; 740 Rust tests and clippy
-D warnings pass on the new lockfile.
Two structural fixes matter as much as the gate itself:
- deny.toml scopes the graph to the targets we actually ship. Without
it the Apple targets pull in plist -> quick-xml and report two DoS
advisories against a crate that is in no binary we release. Ignoring
those by ID would silence them everywhere, including where they
would matter; scoping makes them correctly absent.
- libmpv is pinned by rev instead of branch = "master". A branch means
the revision is whatever Cargo.lock happens to hold and any
`cargo update` silently substitutes new upstream code -- in the one
dependency that is not from crates.io and that links a C library
into the player. The rev is the commit already locked, so this pins
current behaviour rather than changing it.
Licence findings are recorded rather than waved through. libmpv and
libmpv-sys are LGPL-2.1, satisfied here by dynamic linking against the
system library; deny.toml carries the two obligations that follow (keep
the linkage dynamic, ship libmpv's licence text with any bundle carrying
the .so). MPL-2.0 crates are file-level copyleft and fine unmodified.
Releases now publish SHA256SUMS (verified in-job with `sha256sum -c`
before upload) and a CycloneDX SBOM for both halves, so "does this
release contain <vulnerable crate>?" has an answer that is not "rebuild
the tag and re-resolve it".
Workflows pin jellytau-builder:2026.08 instead of :latest. While every
job said :latest, rebuilding the image changed what every build compiled
against, including rebuilds of old release tags.
Also folded in, because both were the same class of problem:
- publish-docs.yml downloaded mdBook from GitHub releases into
/usr/local/bin at job time -- a toolchain install in CI, which
CLAUDE.md explicitly forbids, and a hard dependency on GitHub's CDN
at publish time. It is in the builder image now.
- extract-traces.ts only ever read .ts/.svelte/.rs, so every
requirement implemented by *configuration* was invisible to the
matrix that measures it. DR-205, DR-206, DR-207 and DR-215 all carry
TRACES comments nothing read, and each counted as uncovered while
being covered. Coverage was really 90%, not 88%; MIN_THRESHOLD moves
to 89 accordingly. CI workflows stay excluded and there is a test
saying why: traceability-check.yml quotes "a TRACES: comment" beside
deliberately-undefined example IDs, which the extractor would read
as real traces and then fail its own dangling-ID check.
Supply-chain requirement is DR-216.
🔴 The builder image must be rebuilt and pushed
(scripts/build-builder-image.sh 2026.08) before this reaches master --
the workflows now name a tag and tools that do not exist in the registry
yet.
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.
The shipped designs move into docs/architecture, which is the maintained
description of the build, and the spec files go. Git history keeps the
originals; what a future change still needs is carried across:
- 01-rust-backend: favourites rewritten (the old section named a file that no
longer exists and called shipped buttons "planned"), domain vocabulary owned
by Rust (SearchScope, exclusions, the bitrate ladder), background workers
- 02-svelte-frontend: app shell and chrome, library mosaic, series/episode
navigation, downloaded browse, safe-area insets, native-video store, logging
- 03-data-flow: locally-indexed search
- 05-platform-backends: audio settings on ExoPlayer, the equalizer's band
vocabulary, native video compositing, the background-audio handoff
- 06-downloads-and-offline: one storage model, offline catalog visibility
- 09-security: path confinement and input binding
docs/specs/README.md now says what the directory is for and where each shipped
design went. Deferred work the specs recorded is kept beside the code it
concerns rather than lost: season-bounded autoplay, the two dead search
commands, why indexing is a full crawl.
requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
The repo configured four frontend gates and enforced one of them. eslint
and prettier ran in no workflow and no hook; `bun run check` ran only in
build-release.yml, so a type error could sit on master until somebody cut
a tag; and `bun run test:coverage` had been dead for months.
CI (build-and-test.yml) now runs format:check, lint, check and coverage
alongside the existing boundary and doc-link tripwires.
The coverage script failure was a version mismatch, not a config problem:
@vitest/coverage-v8 resolved to 4.1.10, whose peer range pins vitest
exactly, while package.json asked for ">=1.0.0 <5.0.0" and got 4.0.16 --
every run died on a missing BaseCoverageProvider export. The loose range
is what allowed the pair to drift, so it is now ^4.1.10.
Two ratchets, same policy as MIN_THRESHOLD in traceability-check.yml:
eslint --max-warnings=159 (0 errors; 159 is today's backlog, only
ever lower it)
vitest thresholds (statements 51 / branches 45 /
functions 46 / lines 52, measured at
54.6 / 48.7 / 49.6 / 55.1)
no-console is promoted from "off" to "error": the logger-facade
migration it was waiting on is finished -- 8 calls remained, 2 of them
real stragglers in the settings page, now on the facade the file already
imported. The sink itself, tests, and scripts/ are exempted; a CLI whose
stdout is the product is not a stray debug statement.
The threshold was verified to bite by raising it to 99 and watching the
run go red, not by assuming an unfailed gate works.
DR-205 moves to Done; the coverage gate is DR-215.
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.
This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.
Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
"Prepare Linux artifacts" ran `shopt -s nullglob`, but the runner executes
`run:` blocks with POSIX sh, where shopt does not exist. It exited 127 and
failed the step -- so build-linux never uploaded, create-release (which
needs all three build jobs) never ran, and v0.9.0 and v0.9.1 both compiled
successfully but published nothing. The last release with assets is v0.8.2.
Reproduced under busybox sh: the current block prints "shopt: not found",
passes the unmatched rpm glob through literally ("cp: can't stat
'.../bundle/rpm/*.rpm'"), and exits 127. Without nullglob an unmatched
pattern stays literal, so test each candidate with [ -e ] instead; the
same input then exits 0 with the AppImage and deb copied.
traceability-check.yml already carries this rule in two places (`case`
instead of `[[ == ]]`, a pipe instead of a here-string). Keeping the fix
POSIX rather than adding `shell: bash` follows that convention and drops
the dependency on bash being present in the builder image.
The runner's 74 GB disk kept filling. Measured on the box: 24 GB of
act cache, 23.18 GB of it created in 20 days -- ~1.15 GB/day against a
30-day-unused / 90-day-used GC, so it could never converge.
Cause: src-tauri/target (16 GB locally: 9.6G debug, 3.2G release, 2.4G
android) was cached under five separate keys, all keyed on
hashFiles('**/Cargo.lock'). The release script stamps the version into
Cargo.lock, so all five invalidated on every chore(release) -- 32
distinct lockfile revisions in three months.
- Cache only registry/index, registry/cache and git/db. registry/src is
omitted as well: cargo re-extracts it from the 155 MB of .crate
tarballs rather than storing 1.1 GB extracted.
- Collapse the five per-job keys into one shared cargo-registry key.
They existed to keep debug/release target artifacts from clobbering
each other; with target uncached, registry contents are
target-independent and every job wants the same crates.
- Split cargo-xwin into its own key. It tracks the xwin version in the
builder image, not our lockfile, so keying it on Cargo.lock was
re-downloading the whole Windows SDK on every release bump.
- CARGO_INCREMENTAL=0: never reused across runs, 3.5 GB of the debug dir.
- Installer artifact retention 30d -> 7d; tagged releases carry the
binaries anyway.
Inflow drops from ~5 GB to ~150 MB per lockfile change. Tradeoff: Rust
jobs now compile cold every run (~31min vs ~9min on a cache hit for the
Linux release build). Most runs already paid that, since a release bump
invalidated every key. sccache with a hard size cap is the way back if
it bites.
The rotation interval was installed once when the banner mounted and never
touched again, so a swipe, arrow or dot tap inherited whatever was left of the
running countdown — swiping 5.5s into a 6s interval moved the banner on half a
second later.
The timer moves into heroRotation.ts as a small restartable object so it can be
unit-tested, and every manual navigation path restarts it from that moment.
Verified red-first: with restart() reverted to leave a running timer alone, the
regression test fails.
Release 0.9.1.
The PKGBUILD ran a bare `cargo build --release`. Without
`--features tauri/custom-protocol` Tauri does not embed the frontend and serves
it from devUrl instead, so the package compiled, linked, installed and passed
every check — then launched into "Could not connect to localhost: Connection
refused". `tauri build` passes that feature for you and the Android build passes
it explicitly; this path never did, so the Arch package has never worked.
Adds a check() that catches it at build time. It tests for the *assets*, not for
the dev URL: devUrl is part of the config blob generate_context!() embeds either
way, so grepping for it reports a failure on a correct build. A content-hashed
filename from the vite output can only appear if the bundle was embedded — which
is also why the fixed binary is ~400 KB larger.
Found by installing the package and launching it, which is the only thing that
would have found it.
The prune list was wrong three times running: it walked the scratch worktrees
under .claude/, then makepkg's vendored cargo registry under
packaging/arch/src/, reporting a dependency's broken README as if it were ours.
Every one of those directories is already git-ignored, so asking git for the
file list makes the exclusion rule the same one the repo already maintains —
and it cannot drift the way a hand-kept prune list did.
It also makes the script do what its header always said it did: check tracked
markdown. Untracked-but-unignored files are included on purpose, so a new doc is
checked before it is committed rather than after. The find(1) path stays as a
fallback for a non-git checkout.
CI was unaffected — a fresh checkout has none of those directories — but the
local gate cried wolf, which is how a gate stops being read.
pkgver sat at 0.0.18 while the tree was on 0.8.x, because the Arch package is
built by makepkg rather than the tauri bundler and set-version.sh never touched
it. makepkg produced a package whose version bore no relation to the source it
was built from — the exact failure that script exists to prevent, in the one
file it had missed.
Dev versions are converted to a pkgver Arch accepts: a hyphen separates pkgver
from pkgrel, so 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234. pkgrel resets to 1,
since a new upstream version restarts its packaging revisions.
Also installs LICENSE into /usr/share/licenses — MIT is not in Arch's common
licences, so a package under it has to carry the text.
Arch is not part of the automated release (build-release.yml covers linux,
windows and android), so v0.9.0 is unaffected; this applies to anyone building
the package by hand.
release:notes is not usable for this batch: it maps changed files to their
TRACES, and the logging sweep touched 63 files spanning most of the codebase, so
it reports nearly every user requirement as changed — including ones explicitly
not implemented. Written by hand instead.
Also updates the checklist's artifact names for the rename and adds the rpm,
which the checklist never listed because it was never published.
`docker info | grep Username` only reports a Docker Hub session, so for a
private registry the guard never matched: every push dropped into an interactive
docker login, which hangs a non-interactive run. Checks the credential store for
the specific registry instead, and refuses with instructions rather than
prompting when there is no TTY.
Renaming the app to JellyTau renamed its bundles, and the release job globbed
`bundle/deb/jellytau_*.deb`. The copy was wrapped in `if [ -f ... ]`, so the
rename would have dropped the .deb from the release silently — a green build
producing an incomplete release. Matching by extension removes the coupling
between the product name and the pipeline, and an empty dist/linux now fails
the job instead of passing quietly.
That `if [ -f "dir/"*.ext ]` guard was also wrong on its own terms: with more
than one match, test gets extra arguments and returns false.
Found while verifying the rename: the rpm has been built by every release since
deb+rpm became the bundle targets, and never copied, published or documented.
It ships now.
Also declares the package rename. Tauri kebab-cases productName into the
Debian package name, so "JellyTau" produces `jelly-tau` — a different package
from the `jellytau` earlier releases installed, which would have put a second
copy alongside the old one. deb now declares Replaces/Conflicts/Provides and
rpm Obsoletes/Provides, verified in the built control file.
TRACES: | DR-214
Cargo.toml still carried `description = "A Tauri App"` and `authors = ["you"]`,
package.json's description was empty with no author or repository, and there was
no LICENSE file at all despite package.json declaring MIT.
The user-visible half matters more. productName was the scaffold's lowercase
"jellytau", which is what the Android *release* build shows under its icon and
what the deb/rpm/NSIS bundles carry as their display name. It went unnoticed
because build.gradle.kts overrides the label to "JellyTau Debug" for the debug
build type — the install a developer sees every day was the only correctly-cased
one. mainBinaryName pins the executable filename to "jellytau" so
build-windows-cross.sh and the Arch PKGBUILD, which both resolve it by name,
need no change.
strings.xml moves into the canonical android tree rather than being edited in
gen/, since sync-android-sources.sh already copies res/values/*.xml — so the fix
survives the next regeneration.
Bundle metadata (publisher, copyright, category, descriptions, licence) was
absent entirely, so the packages shipped with no maintainer or description. The
hand-written PKGBUILD and .desktop had all of it; only the generated packaging
was wrong.
Adds .env.example: three scripts require signing vars from a gitignored .env
and .gitignore already whitelists the example, but none existed.
TRACES: | DR-214
The compose services bind-mount the repo and build as root, so every artifact
they leave in src-tauri/target belongs to root on the host. It accumulates:
11,124 such files had built up, enough that cargo clean and scripts/clean.sh
failed with EACCES — and a plain cargo build died part-way through, because
build scripts compile for the host and land in target/debug even when
cross-compiling to Android. That is what blocked the device build in this batch.
Restores ownership at the end of each containerised build, reading the intended
owner from the checkout so no uid has to be plumbed through from the host. A
no-op when not running as root, so the native build scripts call it
unconditionally.
Running the containers as the host uid is the tidier fix and stays open — it
needs the cargo/bun cache volumes moved off /root first, which is why this is
not a one-line user: directive.
TRACES: | DR-213
bindings.ts picks up the library-exclusion commands and types from tauri-specta.
The matrix regenerates because validation.ts and its test are gone — the doc
link checker caught the stale references, which is the first time that gate has
paid for itself on a generated artifact rather than a hand-written link.
Also drops exclusions::is_excluded: a wrapper over is_excluded_by that only a
test called, while the trait impls hoist the snapshot themselves. The test now
calls the same path production does.
Replaces a hardcoded filter that dropped anything named "Podcasts" from music
results — one user's library layout compiled into the shipped product, keyed on
an English literal, applied only at the six call sites someone had remembered.
Exclusion is now a user setting stored in Rust and applied at the repository
layer's convergence points, so scope is decided once and is the same on every
screen. It matches on folder id rather than name: a title is not what an item
is, which is why an album legitimately called "Podcasts" used to vanish.
Deliberately not filtered: get_item (an id asked for by name was navigated to on
purpose, and refusing it would break playback of anything inside a hidden
folder), get_downloaded_items (hiding a download would leave the user unable to
delete a file whose disk usage they can still see), and the offline cache (an
exclusion is a view preference and must be reversible without a re-crawl).
Also removes src/lib/utils/validation.ts — six exported validators with no
caller outside their own test file, which made the module read as covered
input validation while guarding nothing.
TRACES: UR-076 | DR-209 | UT-203
The assertion documents that PathBuf::join discards its base when handed an
absolute path — which is why confinement has to happen after the join, not
instead of it. clippy::join_absolute_paths flags that shape, correctly for
production code, so the lint is allowed here rather than the test weakened.
Worth recording: this lint would not have caught the original defect. The real
join sites pass a variable, and it only fires on a literal.
import.meta.env.DEV is true only under the vite dev server, but
scripts/build-android.sh produces the debug APK with a plain `bun run build` —
so the logger defaulted to warn there too and the debug package lost every
frontend message from logcat. `bun run android:logs` is a documented workflow
that depends on them.
vite now defines __JT_DEBUG_BUILD__ from Tauri's TAURI_ENV_DEBUG, which the CLI
sets while running beforeBuildCommand. The decision is split into a pure
resolveDefaultLogLevel(isDevServer, isDebugBuild) because neither
import.meta.env.DEV nor a vite define can be varied from inside a test.
Also replaces the pinned requirement counts in extract-traces.test.ts with
invariants. The pins guarded nothing the computeCoverage fixtures don't already
cover, while forcing every branch that adds a requirement to edit the numbers —
the comment above them had become a ledger of which branch contributed which row.
TRACES: | DR-204 | UT-201
file_path and target_dir reached PathBuf::join unchecked from the frontend, and
mark_download_completed stored a caller-supplied path that is later fed to
remove_file. A correct sanitiser already existed — download_item_and_start used
it — but download_item is itself a command taking file_path raw, so the guard
was simply routed around. It now lives inside download_item, alongside a
join-then-confine check modelled on media_server::resolve_path.
Sanitising is per path component, not whole-string: the latter would silently
turn downloads/x.mp3 into downloads_x.mp3 and relocate every existing download.
TRACES: | DR-211 | UT-205
Three consistency fixes, each one applying a pattern the same file already
used a few lines away: the offline get_items type filter now binds placeholders
like search at offline.rs:1786 does, build_get_items_endpoint percent-encodes
its values like the Genres block below it does, and player_set_volume clamps
NaN and out-of-range input at the command boundary rather than relying on each
backend to do it.
TRACES: | DR-212 | UT-206
item_id and image_type reached the cache filename unsanitised while tag was
already being sanitised, and Path::join neither folds .. nor keeps the base
when handed an absolute path. Applies the tag's existing rule to all three
parts and adds a starts_with(cache_dir) check at the point of use, modelled on
media_server::resolve_path.
Not exploitable as shipped — server URLs must be HTTPS (auth/mod.rs) and
Android blocks cleartext, so the id would have to come from a server the user
chose to trust. This makes the write path consistent with how the rest of the
codebase already handles caller-supplied paths.
TRACES: | DR-210 | UT-204
Both halves of the path a download writes to arrived from the frontend
unchecked. `start_download` and the queue pump built their target as
`PathBuf::from(target_dir).join(file_path)`, and `mark_download_completed`
stored a frontend-supplied `file_path` on the row verbatim — the same
column that is later read back into `std::fs::remove_file` when a
download is deleted. A correct sanitiser already existed and
`download_item_and_start` used it, but `download_item` is a command in
its own right, so calling it directly routed the guard around.
The guard moves inside. `confine_to_root` folds `..` away lexically and
requires the result to sit inside the storage root, modelled on
`media_server::resolve_path` — the check comes after the join because
`Path::join` drops the base when the joined half is absolute, so an
absolute `file_path` is obeyed rather than folded. `confine_queued_path`
sanitises a queued path per component (so the already-safe name
`download_item_and_start` passes in is not sanitised into a second,
different one) and confines it. Applied in `download_item`, at both join
sites, and to what `mark_download_completed` writes.
Every path the app builds for itself is returned unchanged, including
the absolute ones `download_series`/`download_season` produce from
`${targetDir}/videos`, so no existing row or file on disk is orphaned.
The pump fails an offending row rather than skipping it, because the
pump re-queries and would otherwise not terminate.
Not a live vulnerability: reaching these commands with hostile input
needs script execution in a webview whose CSP is `script-src 'self'`.
This is hardening and consistency.
TRACES: DR-211 | UT-205
player_set_volume passed `volume` through untouched. Each backend
clamps to 0.0..=1.0 for itself, so local playback was already safe, but
the remote branch reaches no backend: it converts with
`(volume * 100.0) as i32`, which turns infinity into i32::MAX. NaN is
handled explicitly since f32::clamp returns NaN for a NaN input and it
then survives every comparison downstream.
TRACES: DR-212 | UT-206
build_get_items_endpoint pasted ParentId, IncludeItemTypes, SortBy and
SortOrder straight into the query string while the Genres parameter
twenty lines below and the SearchTerm parameter both percent-encode
theirs. Encode them the same way, per list element so the commas
Jellyfin splits on survive.
The per-call ids interpolated into request paths (item, person and
playlist ids) get the same treatment; a Jellyfin GUID is unchanged by
encoding, so this is consistency, not a behaviour change. self.user_id
is left alone throughout, as it is at the endpoint builders already.
TRACES: UR-007 | DR-212 | UT-206
The advisory step existed because the tree carried a warning backlog. Measured
on 1.97.1 — the pinned toolchain CI actually uses — that backlog is three
warnings, not the ~51 the comment claimed: two unnecessary_sort_by in
smart_cache and one redundant into_iter in offline. Fixed, so clippy now runs
with -D warnings and a warning means new breakage.
Worth recording why this took a toolchain pin to do safely: the same tree
measured 0 warnings on 1.92.0 and 3 on 1.97.1. Flipping the flag on a local
measurement, without the pin, would have reddened CI on the next push.
TRACES: | DR-206
get_items built its `AND i.item_type IN (…)` fragment by interpolating
each requested type into the SQL string, while `search`, `get_favorites`
and `prune_stale_catalog` in the same file bind the identical filter as
`?` placeholders. Follow the existing pattern so the listing query is
consistent with its neighbours.
The type values bind between the six parent-matching ids and the
favourites user id, matching where `{type_filter}` lands in the
statement.
TRACES: UR-065 | DR-212 | UT-206
The thumbnail cache built its filename from `item_id`, `image_type` and
`tag`, but only sanitised the tag. `Path::join` neither folds `..` nor
keeps its base when handed an absolute path, so a malformed id could
place a cache write outside the cache directory.
Sanitise all three parts through one helper using the rule the tag
already used (non-alphanumerics become `_`), so ids and types that were
already safe keep producing exactly the same filename, and resolve the
result against the cache dir with a lexical `..` fold plus a
`starts_with` check, modelled on `media_server::resolve_path`.
The database still stores the raw key and the resolved path, so the
lookup in `get_cached_path` keeps matching what the caller asks for.
.claude/worktrees holds full checkouts of this repo, generated .svelte-kit
trees included, so 'eslint .' was linting every in-flight branch — 410 errors,
none of them ours. Same root cause the doc-link checker hit.
Adds the frontend's first linter and formatter — the Rust half has had
cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had
only svelte-check. ESLint runs clean; 159 findings are recorded as warnings
rather than suppressed, so the backlog is visible without painting CI red.
Also: `bun run test` no longer drops into watch mode (the "Before Committing"
list told people to run a command that never returns), the traceability ratchet
moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead
of relying on memory, the dead webdriverio e2e suite and its five devDeps are
removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and
the CI builder image stop being five releases apart.
TRACES: | DR-205, DR-206, DR-207
`src/lib/utils/validation.ts` exported six validators (validateItemId,
validateImageType, validateMediaSourceId, validateUrlPathSegment,
validateNumericParam, validateQueryParamValue). Nothing outside its own
213-line test suite ever called them, so the module read as covered,
guarded input validation while guarding nothing — a green test run over
code no input ever passes through.
Deleting it does not weaken any check that was running; it removes the
false assurance that one was.
Note: the layer this validation belongs in per CLAUDE.md ("Validate all
inputs in Rust command handlers") does not implement it either. That is
a separate concern and is left untouched here.
Replaces `src/lib/utils/podcastFilter.ts` — a shipped personal workaround
that dropped any item whose name, album, album artist or artist was
literally "Podcasts" — with a real user setting applied in Rust.
The old filter was wrong twice over: it hardcoded one user's folder
layout keyed on an English literal, and it put a domain rule (what a
query should return) in the presentation layer. It slipped past
`check:boundary` only because it matched on names rather than on an
item-type array.
- `repository::exclusions` owns the rule and the process-wide id set,
the same shape as `online::STREAMING_QUALITY` so it survives a
repository being rebuilt on re-login.
- `HybridRepository` applies it where the cache and server legs of every
cache-first query converge (`parallel_race` / `race_with_refresh`),
plus the bespoke `get_items` path and the server-only reads. Filtering
before the "has content" check is what makes a cache page of nothing
but hidden items fall through to the server.
- Exclusion is by stable item id, never by name, and matches an item's
own id or any container link it carries (parent, album, library,
series, season, artist).
- A direct `get_item` lookup and the Downloads surface are deliberately
unfiltered: hiding those would break playback and file management of
anything inside a hidden folder.
- `LibrarySettings` persists to `app_settings` and is restored in the
setup hook, alongside the streaming-quality cap. Default is an empty
list — nobody inherits the old "Podcasts" behaviour.
- New commands `library_get_settings`, `library_set_settings` and
`library_get_exclusion_candidates`; the candidates read goes through
`get_items_unfiltered` so an already-hidden folder still appears in the
picker and the setting can be undone.
- Settings page gains a "Hidden Folders" section that renders the
backend's candidate list and sends back ticked ids; it decides nothing.
TRACES: UR-076 | DR-209 | UT-203
tauri.conf.json still carried the scaffold defaults: a lowercase "jellytau"
title in an 800x600 window. The title is what the OS shows in the task
switcher and window list, and 800x600 is too small for a media library grid
with a mini player docked at the bottom.
Now "JellyTau" at 1280x800, with minWidth/minHeight held at the old 800x600
so the layout still has a defined floor when a user drags the window small.
Wires scripts/check-doc-links.sh into build-and-test.yml next to the existing
boundary tripwire, and exposes it as `bun run check:links`.
The docs are the maintained source of truth for architecture and process and
cross-reference each other heavily, so a rename that misses a link quietly
turns a doc into a dead end. Pure shell — nothing is installed at job time.
The script itself is landing separately; this job step is red until it does.
CLAUDE.md's five-command "Before Committing" list was enforced by memory
alone. scripts/hooks/pre-commit now runs the half of it that finishes in
seconds — `bun run check`, `bun run test`, check-frontend-boundary.sh, and
`cargo fmt --all -- --check` only when staged files touch src-tauri/.
`cargo clippy` and `cargo test` are left out on purpose. Minutes per commit is
how a hook teaches people to type --no-verify; CI and `bun run test:all` are
where the slow gates belong.
Installed via `bun run hooks:install`, which sets core.hooksPath to the
tracked scripts/hooks directory rather than copying into .git/hooks, so later
changes to the hook reach everyone on their next pull.
The hook runs every gate before reporting, so one commit tells you everything
that is wrong rather than only the first thing. It exits 0 without running
anything during a merge, rebase, or cherry-pick, and when nothing is staged;
`git commit --no-verify` skips it as usual.
The Rust toolchain was unpinned on both sides, and the two sides had drifted
five releases apart: the CI builder image ships rustc 1.97.1, the development
machine was on 1.92.0. Clippy's lint set and rustfmt's output both change
between releases, so a green `cargo clippy` / `cargo fmt --check` locally said
nothing about CI and vice versa — which is the reason the clippy gate could
not be trusted enough to turn on.
src-tauri/rust-toolchain.toml pins channel 1.97.1 with the rustfmt and clippy
components. Deliberately no `targets` list: that would make rustup fetch the
Android and Windows std libraries on every plain `cargo test`, including on
machines that never cross-compile. The image already has them.
Dockerfile.builder installs that exact version instead of "latest stable at
rebuild time", and prints rustc/clippy versions so a mismatch is visible in
the build log.
The pin only becomes authoritative once the image is rebuilt and pushed
(scripts/build-builder-image.sh). Until then CI still runs whatever rustc the
current image has, and if that is not 1.97.1 rustup will download the pinned
toolchain at job time — a toolchain install in CI, which CLAUDE.md forbids.
Both files carry that warning next to the version.
Note: the clippy step in .gitea/workflows/build-and-test.yml is left advisory
here; tightening it wants a warning count measured on 1.97.1 first.
Actual coverage is 90% (`bun run traces:coverage`), so the gate had ~8 points
of slack — a requirement could stop being traced and CI would not notice.
Per the ratchet policy in the workflow, move it up to sit just under the real
figure.
MIN_COVERAGE_PERCENT in scripts/extract-traces.ts moves in lockstep: the
workflow comment says to keep the two in sync and extract-traces.test.ts
asserts it, so changing only the YAML turns the frontend suite red.
(The stale "fails below 50%" comment in scripts/test-all.sh, wrong since the
threshold moved to 82, was corrected in the preceding commit along with the
rest of that file.)
Three gaps in the frontend tooling, all in the package.json script surface.
1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.
Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
`lint`, `lint:fix`, `format`, `format:check` scripts.
The tree is error-clean (`npx eslint .` exits 0). Getting there needed
seven real one-line fixes (braced switch cases that leaked `const` across
arms, a useless regex escape, two `let`s that never change, a thrown Error
that dropped its `cause`, and two `// eslint-disable-next-line` comments
documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
that fires is set to `warn` with the reason written next to it in
eslint.config.js — notably ~94 dead bindings and `any` at the IPC
boundary. Those are real findings to drive to zero, not noise to delete.
`no-console` is OFF for now: a parallel change is moving all ~468 console
calls onto a logger facade, and turning the rule on today would collide
with it. eslint.config.js says so, and says to flip it to `error` once
that lands.
`prettier --write` is deliberately NOT run here — it would rewrite ~200
files and swamp every other diff in flight. The gate is available; the
sweep is a separate commit. Markdown and CI YAML are in .prettierignore
because both are hand-laid-out (and docs/traceability.md is generated).
2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
"Before Committing" list tells people to run it. It is now `vitest run`,
with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
scripts/test-all.sh drops the now-redundant `--run`, and
scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
them to a long-running vitest instead of the single-pass one.
3. The webdriverio e2e suite is deleted. It was last touched in January
("First working POC"), has never run since, and is not in CI — five
devDependencies and two scripts of pure decoration. Removes e2e/,
wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
devDeps, and the WebdriverIO block in .gitignore.
The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
Updates the two referrers outside the docs tree that the move left behind, and
excludes .claude/ from the link checker — the agent worktrees under it are full
checkouts, so it was walking every in-flight branch and reporting their links
as ours.
Every file link in docs/traceability.md was broken — all 2,840. The generator
emitted repo-root-relative hrefs from a file that lives in docs/, so the
artefact the whole TRACES system exists to produce was unnavigable in the repo
browser and on the published site alike. Fixed at the generator and covered by
a regression test, since the markdown output had no test at all.
Also: repairs the remaining broken relative links, adds
scripts/check-doc-links.sh so this class of defect fails a build instead of
rotting, publishes all 50 docs in the mdBook nav (was 21), moves the root-level
build docs under docs/build/ for consistency, retires the stale v0.6.0 audit
after confirming every still-open finding survives in the technical-debt table,
and records the oversized-module debt.
TRACES: | DR-208 | UT-202
build-release.md, build-desktop-packages.md and build-windows.md sat at
the docs/ root while docker.md and build-builder-image.md were already in
docs/build/, so "where do build docs live" had two answers. They now have
one.
Referrers updated: README.md, docs-site/SUMMARY.md, and the ../ links
inside the moved files themselves, which each gained a level of depth —
Dockerfile, Dockerfile.arch, packaging/arch/PKGBUILD, CHANGELOG.md,
README.md, src-tauri/src/lib.rs and src/lib/services/webviewAudio.ts.
Every one of those was caught by check-doc-links.sh rather than by
reading, which is the point of having it.
Two referrers are left for their owners: CLAUDE.md line 173 and the
comment at scripts/build-windows-cross.sh line 11.
484 ungated console.* calls across 63 frontend files shipped to end users —
248 console.log occurrences were verified present in the built bundle. The Rust
half of the app has used the log crate with a LevelFilter and a RUST_LOG
override since the beginning; the frontend had no equivalent.
Adds src/lib/utils/logger.ts: four levels, scoped loggers replacing the
hand-written "[Scope] " prefixes, debug in dev and warn in production, and a
localStorage override so a user can turn verbose logging on in a shipped build
to file a bug report. warn and error are never gated away.
The sweep itself is mechanical — no control flow, error handling, or message
semantics changed.
TRACES: | DR-204 | UT-201
SUMMARY.md drives the mdBook build and mdBook renders only what SUMMARY
references, so 31 of the 52 pages in docs/ were being written, reviewed
and merged without ever appearing on the published site: 25 of the 26
specs (only video-background-audio was listed), plus build-desktop-
packages, build-windows and defect-windows.
The specs are grouped into themed sections — playback, library and
browsing, downloads and offline, tooling and build — because a flat list
of 28 is not navigable, with SPEC-TEMPLATE and SPEC-REVIEW-CHECKLIST kept
together as the process docs someone reaches for before writing a spec.
Walks every tracked .md file, resolves each relative inline link against
the directory the file lives in, and fails with the file:line and the
unresolved target if it is not on disk. Skips http(s)/mailto, pure
anchors, and links inside fenced code blocks (a template being shown to
the reader is sample text, not a live link).
This is the check that would have caught the 2,793 dead links in the
generated traceability matrix at the commit that introduced them, and the
handful of hand-written ones repaired alongside it. Nobody clicks 2,800
links, which is why the defect survived for months.
Two documented exceptions rather than silent ones: docs-site/SUMMARY.md
is copied into docs/ by publish-docs before rendering, so its links are
resolved from docs/ — which is what makes it catch a nav entry pointing
at a page that does not exist; and docs/README.md and docs/api-redirect.md
are generated by that same job and so are absent from the repo by design.
The header states what it deliberately cannot see, in the house style of
check-frontend-boundary.sh: it validates paths, not anchors. Resolving a
fragment needs a renderer's heading-slug rules, which differ between
Gitea, GitHub and mdBook, so a link to a renamed heading still passes.
The package.json script and CI wiring are added separately.
The spec review checklist still asked for >= 50%, the figure the gate sat
at before it was found to be unreachable; traceability-ci.md carried 82%
throughout. Both now read 88%, matching the ratchet, and the checklist
points at `bun run traces:coverage` rather than inviting anyone to trust a
number written in a document.
Also refreshes the two stale coverage snapshots in traceability-ci.md
(~86% from July 2026, and targets of 70% and 90% that the current 90%
already passes) and records the 50 -> 82 -> 88 ratchet history.
- traces-quick-ref.md: the four "where to find requirements" links pointed
at README.md, but those anchors (#1-user-requirements and friends) live
in requirements.md; the "See Also" links were written as if the file sat
at the repo root (docs/traceability.md from inside docs/); and the
extraction-script link needed ../ to reach scripts/README.md.
- release-checklist.md: the release-notes template linked ../../CHANGELOG.md
(one level too deep) and ../../issues + ../../discussions, which are
GitHub relative-URL idioms. The canonical remote is Gitea, whose release
bodies render the template outside any repo path, so these are now
absolute gitea.tourolle.paris URLs. Gitea has no discussions, so that
link is dropped rather than pointed somewhere it does not exist.
- specs/favorites-browsing.md: linked the deleted
src/lib/utils/tauriIntegration.test.ts.
docs/traceability.md emitted each trace's file link with the
repo-root-relative path as the href, but the file is written to docs/ —
so every one of the 2,793 links resolved to docs/src-tauri/... or
docs/src/... and 404'd, in the Gitea repo browser and on the published
mdBook site alike. The matrix is the artefact the whole TRACES system
exists to produce, and it was unnavigable.
The href now carries a ../ prefix; the visible link text stays
repo-root-relative, since that is the path a developer greps for.
This survived because the markdown generator had no test at all — the
existing suite covers counting, coverage and dangling IDs only. UT-202
now generates a link for a file that really exists, resolves the href
against docs/, and asserts the target is on disk; it fails against the
old output. Watched red before the fix, per the red-green rule.
The live-requirements counts move with the rows added in the previous
commit: UR 75 -> 76, DR 194 -> 200, total 337 -> 344.
Adds the requirement rows other work in flight needs so `traces:validate`
stays green: DR-204 (frontend logging facade), DR-205 (ESLint + Prettier
gate), DR-206 (pinned Rust toolchain), DR-207 (pre-commit hook), DR-208
(documentation link integrity), DR-209 (server-side library folder
exclusion) and UR-076, plus §4 test rows UT-201, UT-202 and UT-203.
Deletes docs/codebase-audit.md. It was a 2026-08-16 snapshot of v0.6.0 at
commit be907b49 with no status markers, three releases stale, describing
code that had since changed — a document that half-describes the codebase
is worse than none. Everything in it still genuinely open already lived in
§5's table; the §5 preamble now records what was dropped as closed and
why, so nothing is silently re-raised or silently lost.
Also rewrites §5 row 11 with today's figures and the actual cost: the six
oversized modules are the same ones CLAUDE.md's Gotchas section keeps
having to warn about, which is the price being paid. Recorded, not
scheduled.
Fixes a dead link to the removed src/lib/services/playbackControl.ts,
which now points at src/lib/utils/playbackUnits.ts.
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.
TRACES: | DR-204 | UT-201
The Rust half of the app logs through the `log` crate behind `env_logger`,
with `LevelFilter::Info` by default and `RUST_LOG` to turn the volume up
without a rebuild. The frontend had no equivalent at all: every
`console.log` written during development shipped to end users.
`createLogger(scope)` gives the frontend the same shape:
- four levels (debug/info/warn/error), gated by severity;
- verbose in dev, `warn` in production — warn and error are never gated
away, because a silent failure in a networked media client is worse to
support than a noisy console;
- `localStorage["jellytau:logLevel"]`, read once at init, as the
`RUST_LOG` equivalent so a user can gather verbose logs for a bug
report without a rebuild. Guarded for SSR and for webviews where
storage access throws;
- the scope replaces the hand-written `"[Scope] …"` prefixes;
- a thin pass-through: arguments reach `console.*` untouched and by
reference, and `console` is resolved at call time so devtools
overrides and test spies still see everything.
The test job and build-linux shared one cargo cache key; the test job's
debug artifacts claimed it first and actions/cache skips saving on an
exact-key hit, so Linux release builds compiled cold every time (~31min
vs ~9min for the correctly-keyed Windows job). Same collision between
android-check and build-android. Give the release jobs their own keys.
Also skip build-and-test.yml for chore(release) commits: the tag push
triggers build-release.yml on the same commit, which runs the identical
test suite, and the two ~1h workflows contended for the single runner
slot.
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".
ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.
A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.
Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:
before 13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
(C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
base, 3.5 minutes after the outage with nothing logged between
after 14:05:08 "declining the player's retry", playback undisturbed off the
buffer for 69s (a fatal load error is only raised when the renderer
next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
re-opening at 785.6s -> READY, and no rewind in the following 7 min
Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.
TRACES: UR-040, UR-004 | DR-203 | UT-200
FP5, native ExoPlayer path: the hold follows IS PLAYING CHANGED within 17 ms,
dumpsys shows fl=KEEP_SCREEN_ON on the window, and a pause/resume round-trip
releases and re-takes it. The webview <video> path is still unverified.
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.
Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.
ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.
Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with
./gradlew :app:testUniversalDebugUnitTest
(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.
TRACES: UR-003, UR-004 | DR-202 | UT-199
Validated on device. It was the last item gating confidence in v0.8.0 itself:
R8 stripping JNI-loaded classes has broken release builds here before, and this
release added a new Kotlin path the unminified debug pass did not exercise.
Recorded as closed rather than deleted, so it is not re-raised.
Findings not addressed in v0.8.0, plus items the device-verification pass turned
up, ordered by what would hurt most if left. Highest are the Android 16 Local
Network Protections exposure (LAN Jellyfin access is the app's core function and
enforcement is coming) and the traceability extractor's blindness to the Kotlin
tree, which means the 90% figure excludes a whole platform.
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.
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%.
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.
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).
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.
`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.
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.
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.
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.
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).
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.
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
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.
`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.
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
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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
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
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
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
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
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.
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).
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.
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.
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.
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.
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
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
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
Downloading at a specific quality silently returned the full-size
original. The download URL builder spelled the transcode params
`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. The server discards it without error and then stream-copies the
source, so picking "480p" produced an original-quality file with no
failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected
(case-insensitive binding covers them), which is why the height cap
applied while the bitrate cap vanished.
Also set `allowVideoStreamCopy=false` on the transcode presets to force
a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`,
not `enableAutoStreamCopy` — the latter governs audio only.
`original` is unchanged: it stays a deliberate direct static copy, now
pinned by a test.
The pre-existing unit tests asserted the broken lowercase-r spellings,
so they passed against broken code; corrected. Verified red -> green by
extracting the pre-fix and post-fix builder bodies into an isolated
harness: 15 assertion failures before, 0 after.
TRACES: UR-071 | DR-123
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
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>
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.
scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.
The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.
Two edge cases the previous inline version got wrong:
- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
is still a full ref. That reached the validator verbatim and would have failed
every untagged Android build; a non-tag ref now falls back to git describe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
master landed DR-148 and DR-149 for unrelated audio-decode work (0.4.7/0.4.8)
while this branch was in flight, and both sides claimed the same two IDs. The
native-video requirements move to DR-150 (native rendering behind the flag),
DR-151 (the severed SurfaceView attach chain) and DR-152 (capabilities reported
by Rust). UT-090 was likewise already taken by the seek-bar test, so the adapter
selection test moves to UT-149 and is registered in the table.
The spec header also cited DR-023/DR-024, which are the subtitle and audio-track
selection UI requirements — unrelated to this work. Corrected, with a note so the
wrong IDs are not reintroduced from the draft.
extract-traces.test.ts asserts the live requirement counts on purpose, so adding
three DRs moves DR 144→147 and total 282→285.
Subtitles on the native path are not a regression from this branch: master's
6a712c4 already fixed the root cause (MediaItem.subtitles was hardcoded to
vec![], so ExoPlayer always received zero SubtitleConfigurations) and that fix is
now underneath these commits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spike's central question — can a SurfaceView be composited behind a
transparent Tauri WebView on Android — is answered yes, verified on a physical
device. No upstream issue blocked it and none demonstrated it; this appears to
be the first working instance.
Marks DR-148 done behind the flag and records what is confirmed versus what is
still open: playback and positioning are verified, but the individual native
controls (seek, audio-track, subtitle), the mini-player transition, and the
MediaCodec hardware-decode claim are not yet each measured. The mini-player
transition is called out as the known gap, since it is the one case where the
fullscreen assumption behind "no rect plumbing needed" does not hold.
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>
The DR-149 row lost an index race with a parallel session's edit of the same
file, so the previous commit carried the count assertion (DR 144, total 282)
without the requirement it counts — a clean checkout of that commit failed
`bun run test` against its own requirements.md.
The parallel session also reached UT-143 and UT-147 for subtitle work, which
collided with the UT-143 used for the client-side transcode tests. Those move
to UT-148, in the table and in the device_profile TRACES comments, so no two
requirements share an ID.
Advertising a webview-shaped profile (DR-148) was necessary but not
sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a
DirectPlayProfile's Container and VideoCodec — excluding either returns
SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported /
VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3
track is still offered for direct play against a profile listing only
aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the
codec nor MaxAudioChannels:2 against a 6-channel track changes the answer,
so no profile the client can send fixes this and the picture plays silent.
The client therefore stops delegating a question it can answer itself. The
negotiated source's audio is checked against what the webview decodes, and
an undecodable track forces the existing h264/aac HLS transcode regardless
of the server calling direct play fine; direct_play and needs_transcoding
are corrected to match so the frontend and the reporting path agree with
the URL actually used. The track judged is the one that would be served —
the default, else the first — since a supported track further down is not
the one that plays. A source with no audio, or a codec the server did not
name, is left alone rather than transcoded on a guess.
Test-first: the new tests failed against the old behaviour before the
decision existed. Verified on a motorola edge 30 by the audio HAL, not by
ear — the same E-AC-3 episode logged isMusicActive=true once and 58
ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC
file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on
the signed release build is unaffected.
Also carries in-flight subtitle-track work authored in a parallel session
(subtitleTracks, VideoPlayer, player/media, bindings) at the user's
request, so the tag matches the APK verified on device.
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
Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.
The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.
Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.
Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).
Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.
Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.
Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.
Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.
TRACES: UR-020 | DR-023 | UT-143, UT-144
The audio codec list sent to Jellyfin comes from MediaCodecList, which
describes ExoPlayer — but video does not play through ExoPlayer. Android
force-renders every video in the webview <video> element (the interim
override in VideoPlayer.svelte) and Linux always has, and Chromium/WebKit
decode a far narrower set than the platform does.
A motorola edge 30 ships /vendor/etc/media_codecs_dolby_audio.xml, so it
reported ac3,eac3; the server direct-played an E-AC-3 track with
static=true and the webview built a video decoder and no audio decoder at
all — full picture, no sound. The defect is triggered by capability rather
than the lack of it, which is why a Fairphone and an Honor tablet play the
same file on the same build: without the Dolby decoder they never claim the
codec, so the server transcodes to AAC. Confirmed by A/B on the failing
device — hevc+eac3 silent, hevc+aac audible, same session, same profile,
same direct-play path, audio codec the only variable.
video_audio_codecs narrows the platform list to the webview-decodable set
for the video direct-play profile only. Audio-only playback really is the
native player's, so that profile keeps the full list rather than
transcoding music that plays perfectly well. A list with nothing decodable
still claims aac, since a profile claiming nothing invites the server to
give up instead of transcoding. The video codec list is deliberately
untouched: HEVC direct-plays through the webview correctly, so the
constraint is specific to audio.
Test-first: the tests failed against the old behaviour before the filter
existed, including the case built from the phone's real codec list. The
requirement-count assertion in extract-traces.test.ts moves 280 -> 281 for
the added DR, which is the deliberate edit that test exists to force.
Not yet verified on device — the 0.4.7 APK was still building.
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.
When ExoPlayer selected no audio track, the recovery forced group 0 /
track 0 unconditionally. But the most likely reason nothing was selected
is that this very track cannot be decoded on this device, so the override
reinstated the silence it was meant to fix.
Scan the groups for the first isTrackSupported track and override to
that. Also clear setTrackTypeDisabled(TRACK_TYPE_AUDIO), since audio may
equally have been off at the type level, which an override alone does not
undo. When no group holds a supported track, log it as an error — the
server was expected to transcode — instead of leaving a silent video with
no explanation in the log.
Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set, as noted in the previous commit.
Video manages audio focus by hand (handleAudioFocus=false, since
ExoPlayer's automatic handling is reserved for the audio path), and all
three outcomes of the request were treated as success. AUDIOFOCUS_
REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly
invites, and which means the system is withholding our audio until it
calls back — and an outright REQUEST_FAILED were logged and then followed
by playWhenReady = true. The picture rolled with no sound, which to the
user is indistinguishable from a broken stream.
Hold playback when focus is not granted and start it from the
AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of
resuming into a stream the system is still muting, guarded by a
held-focus flag so repeated plays do not leak focus requests. LOSS clears
the pending flag so an unrelated later GAIN cannot start playback the
user never asked for.
Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set (the Gradle project lives in the
generated, gitignored gen/ tree), so the logic cannot be exercised off
device without restructuring the Android build.
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.
MediaCodecList answers "can this device decode 5.1", which is not the
question that decides whether the user hears anything: a phone decodes an
AC-3 5.1 track happily and still has two channels to play it out of. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.
Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so a device with genuine
surround output keeps direct-playing it. A missing or zero reading means
"route not yet established", not "no audio", and falls back to stereo.
Jellyfin's MediaStream.Index is global across every stream in a media
source, so index 0 is the video stream on virtually all files. We sent
AudioStreamIndex=0 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 — 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 the bug, which is why it
surfaced as "some videos have no audio".
Omit the parameter unless a track was actually chosen, so the server
resolves the source's DefaultAudioStreamIndex. An explicit selection from
player_switch_audio_track still passes through unchanged. Dropped
outright from the static=true direct-play URL, which serves the original
file untouched.
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
Two MPV-side fixes for the same failure story — a wifi blip during
playback.
The demuxer gave up the moment a read failed and MPV raised
EndFile(ERROR), so a momentary outage killed the track outright. Enabling
ffmpeg's reconnect options handles the common case entirely below our
level, so most outages never reach the recovery path at all. Set
non-fatally: their availability varies with the libmpv/ffmpeg build, and
losing resilience is not a reason to refuse to play anything.
Separately, `time-pos` and `duration` are live properties of the *loaded*
file: at EOF MPV unloads it and both stop resolving. Reading them straight
through returned 0.0/unknown at exactly the moment end-of-file handling
needed to know where playback had reached, so the player appeared to
rewind to 0:00 as a track ended. `ObservedTime` records the last reading
seen while media was loaded and the accessors fall back to it.
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.
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.
None of the app's safe-area handling was ever active, for two independent
reasons:
1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
resolved to 0px — the padding in app.css and BottomUi was a no-op.
2. Android WebView maps only the *display cutout* into `env()`; the status bar
and navigation bar are never reported. With enableEdgeToEdge() and
targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
spans them, so CSS could not learn about them by any route.
WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.
Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.
The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.
Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
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
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.
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.
On Android, dragging or tapping the progress bar moved the thumb but
playback stayed where it was. Two separate defects, both touch-only,
which is why the mouse-driven scrub tests never caught either.
1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that
land on a control, but handleTouchMove kept running. It measures
against touchStartX/Y, which that early return leaves at the PREVIOUS
gesture's values, so a seek-bar drag produced a huge bogus vertical
delta: read as a brightness swipe, it dimmed the screen to the 0.3
floor and fired a spurious play/pause "correction" mid-drag. A gesture
is now latched at touchstart (playerGestureActive) and touchmove
ignores anything unlatched — re-checking the move target cannot
recover a start point that was never recorded.
2. Commit signal. 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. touchend/mouseup now commit too; `input` arms a one-shot
latch so whichever release signal arrives first commits and the other
is a no-op. seekRelative shares the same commitSeek entry point
instead of fabricating a synthetic change event.
Tests drive the slider with real touch events (UT-089, UT-090) and fail
against the pre-fix component.
Both still described the 300ms deferred-tap design that DR-098 replaced
with immediate action, so the generated release notes advertised
behaviour the code no longer has.
The control-surface guard added in the previous commit killed
double-tap-to-seek. The first tap pauses, which renders the full-screen
<button> play overlay over the video, so the SECOND tap lands on a
button — and the guard discarded it as "a tap on a control".
Mark that overlay `data-player-surface`: visually it IS the video, so it
must keep taking tap gestures despite being a <button>. The marker wins
over the interactive-tag check in isControlSurfaceTouch.
Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component
and dispatches real touch/click events at whatever element is genuinely
on top. This is the gap that let four bugs ship in a row: the pure-unit
tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all
passed throughout, because each helper behaved exactly as specified —
every bug was in the composition, i.e. which element actually receives a
tap after Svelte re-renders. Modelling that DOM by hand in a test would
just re-encode the same wrong assumption, so these render it instead.
The new double-tap test was verified to fail with the fix reverted and
pass with it applied, in both directions.
The bottom play/pause button did nothing. The gesture listener lives on
the outer container and touch events bubble, so tapping the button ran
handleTouchStart (toggle #1) and then the button's own onclick (toggle
#2). The two cancelled out, leaving the control apparently dead.
Ignore container-level gestures for touches that land on an interactive
control: buttons, links, inputs (the seek bar), or anything inside the
controls bar, now marked `data-player-controls`. The rule itself is a
pure function over the ancestor chain (isControlSurfaceTouch), so it is
unit tested without a DOM.
Same root shape as the play-overlay bug in the previous commit: a second
click target over the video that the gesture layer did not account for.
After the DR-098 tap rewrite, pausing became impossible while unpausing
always worked — an asymmetry that pointed straight at the overlay.
Pausing renders a full-screen play-overlay button over the video. The
compatibility click Android synthesizes from the tap arrives ~30-130ms
later, by which time that button exists, so the click lands on the
OVERLAY rather than the <video>. Its onclick called togglePlayPause with
no guard at all, resuming immediately. Unpausing was unaffected because
it removes the overlay, leaving nothing to intercept the click.
The suppression rule was only wired into the video element's handler.
Extract it as isSynthesizedTouchClick() in tapGestures.ts (unit-tested)
and use it from every click target layered over the video, the overlay
included.
Verified: 724 frontend tests pass, svelte-check clean. Bumped to 0.2.5
so the APK installs over 2004.
Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.
The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.
Replace the deferral with immediate action — there are only first and
second taps:
1st tap: toggle play/pause
2nd tap: seek, then toggle play/pause again
The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.
Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.
A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.
UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
An unexplained pause/resume loop was invisible over adb: handlePause
logged nothing at all, so only the "playing" half of each cycle showed
up, and the 1s debug tick logged an object — which the Android WebView
console bridge renders as "[object Object]", discarding every field.
Log the element state on pause (readyState, networkState, seeking,
ended, plus the component's own isSeeking/isBuffering/handoff flags) and
emit the debug tick as a flat string. This is what identified DR-097:
the element was fully buffered and healthy at every pause, ruling out a
stall and pointing at a competing controller instead.
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
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.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.
The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.
Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.
Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
An on-device test build compiled all four ABIs (arm64/arm/x86/x86_64),
so three of the four Rust compiles were thrown away. That dominated the
build time when iterating against a connected phone.
--device resolves the attached device's ABI via adb and targets just
that triple; --abi <target> selects one explicitly; ABI= works as an
env var. Default behaviour is unchanged (all four), since a
distributable universal APK genuinely needs them.
bun run android:build:device
bun run android:build:release:device
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.
Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.
This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.
Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
Seeking near the end of a transcoded video locked the player into a
stall/pause loop: unpausing or skipping bounced straight back to paused.
Both seek paths clamped the target to exactly `duration`. hls.js then
requested the segment whose start time lies *past* the end of the media
(a 6330.324s item asks for segment 1055, starting at 6336.33s). Jellyfin
never produces that segment, the fetch times out, and the gap-controller
stalls forever at the last buffered position — retrying ~1x/second and
firing an endless stream of AbortErrors as play() lands mid-nudge.
Clamp strictly inside the media instead, keeping one segment length
(6s) of margin, floored at 0 so short media still seeks to the start.
The seek-bar drag path needed this too: its range input `max` is the
duration itself, so dragging fully right produced the same dead target.
Also bumps the requirement-count fixture for the new DR-095 row.
check:boundary passed on the very leak it was written for. The pattern was
anchored to `includeItemTypes:` at the query site, so searchScope.ts
assigning the same array to a named const and dereferencing it one
indirection away was invisible — through every green CI run.
The check now matches an array literal naming two or more Jellyfin item
types anywhere in src/, catching a const, a Record value, a function
return, and an inline query alike. Deliberate limits kept: two adjacent
literals required (single-type presentation stays legal), string literals
required (item.type === "Audio" is display logic), explicit type list
(so ["High","Low"] produces no noise).
Verified all five cases: reintroducing the original SCOPE_ITEM_TYPES
fails; a new const ["Movie","Series"] fails; the same array in a
.test.ts passes; itemType: "Movie" / item.type === / ["High","Low"] pass;
a 5th allowlist entry fails on the new cap.
Allowlist 1→3 entries, capped at 4 so the next exception forces a
conversation rather than a one-line append:
- GenericMediaListPage: grid styling over a self-declared itemType —
presentation, changes only with a UI redesign.
- DownloadedBrowse: borderline, leans domain (the container set grows
when Jellyfin adds a container type). Allowlisted with a TODO for a
backend MediaItem.isContainer flag.
The header now names what the check still cannot see — run-time-built
sets, types split across variables, switch/|| taxonomy — and CLAUDE.md
states that a green check:boundary is not proof. That matters given this
check passed on its own founding violation for months.
Also: both gates wired into test-all.sh, which called `bun run test`
without --run and would have hung in watch mode. Corrected the Dockerfile
comment describing the Windows toolchain as mingw/GNU — it is MSVC via
cargo-xwin (GNU cannot bundle NSIS from Linux).
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.
All three shared one root cause: an unscoped `grep -r src-tauri/`, which
walks ~40GB of target/ build artifacts.
- check-req-coverage.sh: also read README.md, which has held zero
requirement rows since they moved to docs/requirements.md. Reported
"Total Requirements: 1", zeros in every category, then printed
"All requirements have implementations!" — the opposite of a warning,
from an empty result set.
- check-test-coverage.sh: hung indefinitely, no output at all.
- find-req-implementations.sh: same hang.
None was referenced by CI, package.json, or the docs.
They were salvageable — the greps just needed scoping — but they read an
undocumented `@req:` / `@req-test:` tag convention parallel to `TRACES:`
(146 and 76 occurrences, described in no doc; CLAUDE.md documents only
TRACES). Repairing them would re-establish the second source of truth
that let "1 requirement" and "211 requirements" coexist unnoticed.
extract-traces.ts is now the single owner of coverage reporting.
The existing @req:/@req-test: comments are left in place: harmless as
prose, several encode useful test intent, and stripping 222 comments is a
large diff with no functional gain. They are simply no longer read.
The coverage gate divided traced counts by hardcoded literals (UR/39,
IR/24, DR/48, JA/3, TOTAL_REQS=114) that had fallen out of date as
requirements grew to 211. It reported 158% coverage — JA alone printed
800% — so the 50% threshold was mathematically unreachable and the job
could not fail. Coverage could have collapsed to 30% and CI would still
have printed a green tick.
Real coverage is 86%. The number was fine; the gate was dead.
extract-traces.ts now owns both sides of the fraction:
- countDefinedRequirements() counts an ID only where it leads a markdown
table row, ignoring the "Traces To" column and prose. IDs are
deduplicated because requirements.md lists every UR twice (§1
definition + §3 matrix), which would otherwise report UR as 121/61.
- computeCoverage() uses the intersection of traced and defined IDs, so
a TRACES comment naming a deleted or typo'd requirement is reported as
`orphaned` rather than inflating the ratio past 100%. UT/IT test
identifiers are excluded as a separate taxonomy.
- CI reads .coverage.percent and fails on <50% or >100%; a >100% reading
is now a hard error rather than the condition that hid this bug.
- New `bun run traces:coverage` runs the same computation locally.
- scripts/ added to the scan roots — the coverage tool was invisible to
the matrix it generates.
Tests written first (15, over fixtures so they don't drift as
requirements are added). vitest include widened to scripts/** so build
tooling is covered by the normal suite.
Verified empirically rather than by inspection: forcing the threshold to
99% fails; adding a requirement lowers coverage 86%→85%; a TRACES: DR-999
lands in `orphaned` without changing `covered`.
traceability-ci.md documented the same stale numbers and would have let
the broken arithmetic be reconstructed — replaced with a pointer to the
live command.
Audit of the principles in CLAUDE.md and docs/architecture/ against the
actual code. Principles with a working automated check (poison-tolerant
locking, Android source sync, one-directional playback state, graceful
backend init, reachability-from-traffic) all held up. The two that drifted
are exactly the two whose checks were broken or too narrow:
- traceability-gate-repair: CI divided by hardcoded denominators
(UR/39, IR/24, DR/48, JA/3, total 114) while requirements.md had grown
to 211, reporting 158% coverage — the 50% threshold was unreachable and
the job could not fail.
- req-coverage-script-removal: check-req-coverage.sh reports
"1 requirement" and prints "all requirements have implementations".
- scoped-search-boundary-implementation: the founding boundary incident
was specced but never built; the leak is still live.
- boundary-tripwire-hardening: check:boundary passes on that same leak —
the pattern is anchored to the query site, so a named const evades it.
- player-facade-enforcement: 52 direct commands.player* call sites
outside the facade, and no automated check at all.
Each spec follows SPEC-TEMPLATE.md with a filled-in Layer assignment
table and is checked against SPEC-REVIEW-CHECKLIST.md.
A running JellyTau currently reports no version anywhere: not in the UI, not in
the logs. When a user reports "the equalizer does nothing on my device" there is
no way to tell whether they are on the v0.2.0 tag, master, or a three-week-old
local debug build — a live gap given v0.2.0's Android audio settings are not yet
device-verified.
Specifies a build.rs-emitted `git describe --tags --always --dirty`, a typed
BuildKind (Release/Untagged/Development/Unknown) classified in Rust rather than
pattern-matched in the UI, a get_build_info command, startup logging, and a
Settings > About block with copy-to-clipboard for bug reports.
Explicitly does NOT derive the release version from git: Cargo needs a literal
semver at manifest-parse time, so sourcing it from a tag would trade a
reviewable bump for a build-time dependency that fails in CI's shallow Docker
clones. The release version stays authored; only the provenance is derived —
they answer different questions.
Two constraints found while writing this:
- Only publish-docs.yml sets fetch-depth: 0. build-release.yml has five
checkouts and build-and-test.yml two, all of which would stamp "unknown"
as-is. Flagged as an acceptance criterion.
- tauri.conf.json's version field can be dropped to fall back to Cargo (three
hand-bumped files becomes two), but gen/android/app/build.gradle.kts reads
versionName/versionCode from generated Tauri properties, so that must be
verified before adopting rather than assumed.
Minor bump rather than patch: Android gains working equalizer, volume
normalization and gapless playback, which are new user-facing capabilities.
CHANGELOG entry written by hand rather than from `bun run release:notes`. The
generated draft lists "Crossfade between audio tracks (UR-031)" as a feature of
this release, which is false — the trace graph cannot distinguish code that
plumbs a setting (settings.rs clamping, the backend.rs trait method, both
legitimately tagged DR-034) from code that implements it, and crossfade is
implemented nowhere. The release-notes tool documents its output as a reviewed
draft; this is a concrete case of why.
v0.1.3-v0.1.5 have no CHANGELOG entries; noted in the file rather than
backfilled.
ExoPlayerBackend was the only backend not overriding the PlayerBackend trait's
set_audio_settings/audio_settings defaults, so the Settings > Audio controls
rendered on Android and silently did nothing — the default returns Ok(()) while
applying nothing, so the failure was invisible.
Rust owns what the values are (canonical 10-band ISO layout, preset curves,
normalization presets); Kotlin owns when the AudioEffect objects exist, since
that needs the live audio session id.
- settings.rs: audio_settings_jni_payload() sanitises (crossfade clamped, band
vector normalised) before serialising, so a malformed vector cannot reach the
Kotlin parser. JSON rather than a wide JNI signature, matching how load()
already passes subtitles — adding a field will not change the signature.
- ExoPlayerBackend: set_audio_settings/audio_settings over JNI; ExoPlayerState
gains the first command-side field (settings are pushed out, never reported).
- JellyTauPlayer.kt: Equalizer, LoudnessEnhancer, and gapless via
pauseAtEndOfMediaItems.
Three details that are easy to get wrong:
- Effects re-attach on onAudioSessionIdChanged. ExoPlayer rebuilds its audio
sink on a format change, which invalidates effects bound to the old session;
without this the EQ silently stops applying mid-queue.
- All effect work is posted to mainHandler rather than run inline. AudioEffect
construction from a player callback can re-enter the player and deadlock —
the same shape as the AutoplayDecision lock-scrutinee bug.
- Device equalizers expose a device-dependent band count (commonly 5) at fixed
centres, so the canonical 10 bands are resampled by nearest centre frequency.
resampleBands() is a pure @JvmStatic function so that mapping is testable
without a device.
Normalization is approximate, not parity: LoudnessEnhancer is a gain stage, not
a true EBU R128 normalizer like MPV's dynaudnorm. Recorded as such rather than
claimed as equivalent.
Crossfade is deliberately excluded — unimplemented on every platform and
blocked on mpv, so building it on Android alone would invert the parity gap.
Tests written first and observed failing (cannot find function
audio_settings_jni_payload) before the implementation: the payload contract is
pinned by tests because a serde rename would otherwise silently break the
Kotlin parser.
Not yet verified on a physical device — AudioEffect availability and band
layouts are device-specific. Requirements matrix marks these rows accordingly,
and flipping the trait default to Err(not_implemented()) is deferred until that
verification lands.
Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer,
Windows/webview) onto one engine with hardware acceleration. Conclusion: video
cannot be unified onto a native engine; audio can.
The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each
draw into their own compositor surface, so a native video surface sits either
entirely above or entirely below the webview and cannot interleave with HTML.
GStreamer and libVLC fail identically. mpv would additionally regress streaming:
it has no adaptive bitrate, while the current hls.js path does.
Six specs added:
- playback-backend-unification: the analysis and decision, with evidence
- android-audio-settings-parity: set_audio_settings on ExoPlayerBackend
- android-native-video-spike: timeboxed test of SurfaceView compositing
- windows-native-audio-backend: replace the webview <audio> shim with libmpv
- libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file
- playback-docs-corrections: the requirement-status fixes applied here
Corrections to requirements.md, all verified against source:
- UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented
nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on
mpv, whose single-stream audio chain cannot feed acrossfade's two inputs.
- Parity matrix listed crossfade as a Linux/Android gap; it is neither.
- The matrix omitted the equalizer, which has the same Linux-only shape.
- The suggested ConcatenatingMediaSource is deprecated in current Media3.
nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android
video. That issue is a stale feature request, dead since 2024-07-01; the
capability shipped in tauri 27d01834 (2024-09-02), and the related
black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely
unproven is SurfaceView-behind-WebView compositing, which the spike now tracks.
Stopping the backend makes the native player fire its ended callback,
which lands in on_playback_ended. 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 runs, showing a
next-episode popup (or advancing outright) right after the user's sleep
timer expired.
Record EndReason::UserStop before the stop reaches the backend. That is
the honest label: the stop was user-initiated, just via the timer they
set rather than the stop button.
TRACES: UR-023, UR-026 | DR-029
A tap cannot be classified when it lands — it may still turn out to be
the first half of a double tap. Play/pause is therefore deferred until
the 300ms double-tap window closes, and cancelled outright if a second
tap arrives, so a double tap seeks without also toggling pause.
Forward skip moves from 10s to 30s (back stays 10s), for both double tap
and the keyboard arrows.
The timing rules live in tapGestures.ts so they are unit-testable
without mounting the player. Rapid double taps now chain off a
still-in-flight seek target instead of all resolving against the same
not-yet-updated position.
TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
Locking the screen killed audio on video playback even with the
background-audio toggle armed.
configureWebViewForMedia() ran from onCreate's delayed post AND from
every onResume, re-calling addJavascriptInterface on each pass — five
times in a 45s session. WebView binds injected objects at page-load
time, so re-injecting over a live page leaves JS holding a stale proxy:
the object stays truthy (passing the `bridge()?.` optional chain) while
its methods vanish. Logcat showed 66 "WebView: Unknown object" errors
and, in JS, "TypeError: setEnabled is not a function".
So the toggle turned blue but never reached native. backgroundAudioEnabled
stayed false, onStop never dispatched 'jellytau-background', the handoff
never ran, and audio stopped the instant the screen locked. PiP and audio
focus broke identically.
- Register the bridges exactly once per WebView (identity-compared), and
split the idempotent settings/chrome-client work into
configureWebViewSettings() so it still runs on every resume.
- Forward WebView console output to logcat as "JellyTauWeb". The frontend
was previously invisible to adb, which is what made this bug so hard to
place; keep it for the next boundary-spanning diagnosis.
- setBackgroundAudioEnabled now reports whether native was actually
reached instead of silently no-oping, so a dead bridge can never again
masquerade as an armed toggle.
Removing the re-injection revived a latent conflict it had been masking:
the focus calls started working, and three AUDIOFOCUS_GAIN requesters
inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's
own AudioFocusDelegate. The grant was followed ~45ms later by
AUDIOFOCUS_LOSS, whose handler paused playback, so arming background
audio (or just pressing play) paused the video in a loop.
WebView already manages focus for <video>. Drop the redundant
AndroidAudioFocus bridge, its listeners and its helpers entirely, and
leave focus to whichever engine is actually rendering — consistent with
the player-is-authoritative principle.
Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused
since the button gate moved to platform().
TRACES: UR-040 | IR-025, DR-051 | UT-062
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.
Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
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.
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.
Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
CLAUDE.md now 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.
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:
- Series that expose episodes directly as children rather than under
season folders yielded an empty season fetch, leaving allEpisodes
empty. The library page now groups those flat episode children by
their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
compared equal (undefined === undefined) and every one of them looked
like the focused episode.
Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.
TRACES: UR-058 | DR-087
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
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
Adds a build-windows job to the release workflow, cross-compiling the
Windows NSIS installer from Linux via the builder image (MSVC target +
cargo-xwin, no toolchain installs). Wires its artifacts into
create-release alongside Linux and Android.
TRACES: UR-003 | DR-004
Adds Docker-based packaging for Linux desktop (deb/rpm), Arch
(.pkg.tar.zst via makepkg), and Windows. Windows cross-compiles from
Linux via the official Tauri path — the x86_64-pc-windows-msvc target
driven by cargo-xwin — and produces an NSIS installer (nsis via
tauri.conf.json targets, since the CLI rejects --bundles nsis on a Linux
host). Verified end to end: builds jellytau.exe + jellytau_x64-setup.exe.
Unifies everything on one registry builder image (Dockerfile.builder):
Android SDK/NDK, rpm/file, clang(+clang-cl)/lld/llvm/nsis, cargo-xwin and
the msvc target. Packaging tools sit in a trailing layer so tool changes
rebuild in ~1min instead of ~15. Desktop stages are thin FROM
${BUILDER_IMAGE} layers; Arch uses a separate archlinux image.
CLAUDE.md: CI must install no system toolchains — everything lives in the
image. Bumps version 0.0.18 -> 0.1.0 (Windows support + webview audio +
equalizer).
TRACES: UR-003, UR-005 | DR-004
Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g.
Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it
emits a WebviewAudioLoad event with the stream URL; a frontend <audio>
element (WebviewAudioAdapter + webviewAudio service) plays it and reports
state/position back through the existing player_report_* round-trip, so
the Rust PlayerController stays the single source of truth. Play/pause/
seek reach the element via the existing ControlCommand event.
All video already renders in the webview on every platform, so this
completes audio-only playback for Windows (video via WebView2, audio via
<audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux.
Regenerates bindings.ts (adds webview_audio_load; also carries the
equalizer EQ bindings).
TRACES: UR-003, UR-004, UR-005 | DR-004
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
Home carousel cards route a tap to the item's detail / Episode Focus
View and a ~500ms long-press to a confirm-then-play flow. MediaCard gains
an onLongPress prop with pointer-based detection (cancelled on >10px move
so carousel scroll is unaffected, trailing click suppressed). Episode
taps route to /library/<seriesId>?episode=<id>; the bare-episode detail
page links back to its parent series/season.
TRACES: UR-058 | DR-087
The browsable Downloaded library + Transfers split + on-disk usage
(f25deba, plus today's grouping/perf fixes) fully implement UR-055 and
UR-056, but the requirements doc still listed them and DR-081..085 as
Planned. Flip to Done.
Also fix UT-id collisions: the downloaded-browse and formatBytes tests
reused UT-046..050 (already assigned to smart-cache/playlist tests in the
matrix). Reassign to UT-071..078 and register them in §4, including the
new music/TV container-rollup and orphan-leaf regression tests.
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.
The DR-079/DR-080 root-cause fixes for issue #10 landed in 8f4f651; the
requirements doc still listed UR-052 (and DR-078/079/080, UT-068/069/070,
IT-016/017) as Broken/Partial/Pending. Flip them to Done and regenerate the
traceability matrix. Existing backend tests already cover the IT-016/017
end-to-end scenarios (annotated with their IDs in the code commits).
The audio-only (background-audio) button was gated on the
AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount.
The bridge is injected into the WebView asynchronously and races component
mount, so on some loads the probe returned false and never recovered,
hiding the button on 'some videos' at random.
Gate on platform() === 'android' instead (synchronous, stable), matching
the convention in VolumeControl. toggleBackgroundAudio() already no-ops if
the bridge is momentarily absent.
Bump version to 0.0.18.
Settings page refactor plus supporting docs (requirements, ux-flows,
traceability) and the frontend-domain-model spec with implementation-status
banner. Removes SkeletonLoader and StorageManagement components (no remaining
references).
library/movies/+page.svelte handleItemClick still read item.type === "Folder"
(missed in the phase-2a sweep). Now item.kind === "folder". Final sweep
confirms zero catalog .type/runTimeTicks/primaryImageTag/playbackPositionTicks
reads remain anywhere in src/.
Decision on dropping the dual-carry legacy Rust fields: KEPT. They are now
internal-only (no frontend reader), but internal Rust still depends on
item_type (SQL WHERE item_type='Audio', player audio filter, player-queue
passthrough) and the DB stores ticks. Removing them needs a DB/query-layer
migration with real regression risk and zero frontend benefit — out of scope
for the frontend-model goal, which is met.
Frontend 626, check clean.
jellyfinFieldMapping.ts (SORT_FIELD_MAP friendly->Jellyfin sort names) had
zero consumers — sort code passes raw Jellyfin field names directly — so it
and its test are deleted.
playbackUnits.ts can't be removed: its tick<->seconds helpers are still the
correct converters for the remote Jellyfin *session* boundary
(SessionInfo.playState.positionTicks, NowPlayingItem.runTimeTicks), which
legitimately arrives in ticks. Documented that narrowed role; formatTime/
calculateProgress remain neutral seconds-based presentation helpers.
Note (out of scope): sortBy still passes raw Jellyfin field names
("SortName", "CommunityRating") — a separate sort-taxonomy leak that would
need its own Rust SortKey, like the search-scope work.
Frontend 626 tests (jellyfinFieldMapping's 18 removed with it), check clean.
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.
The library detail page showed the raw Jellyfin item_type string
("MusicAlbum") to users. Add utils/mediaKind.ts with kindLabel(), a
presentation-only MediaKind -> human label map, and use it for the badge.
Flip the two remaining .type debug logs to .kind.
This removes the last user-visible Jellyfin vocabulary on the catalog
surface. primaryImageTag -> imageId rename (naming-only, ~40 sites across
catalog + player/merged types needing a Rust round-trip) intentionally
deferred as the lowest-value slice.
Frontend 644 tests, check clean.
The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.
Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
at the offline mapping seam via domain::ticks_to_ms.
Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.
Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).
Rust 456, frontend 644, check + check:boundary clean.
isVideoItem now reads item.kind, so the mini-player visibility fixtures
must set kind (track/movie/liveChannel) instead of the old Jellyfin
type strings. Renames channelItem -> liveChannelItem to match its kind.
All 644 frontend tests pass.
The catalog MediaItem check in stores/player.ts isVideoItem() was missed
in the phase 2a sweep (excluded by a player-path filter). It reads the
catalog MediaItem, so it flips like the rest: type Movie/Episode/TvChannel
-> kind movie/episode/liveChannel.
Verified: no catalog .type === "<JellyfinType>" comparisons remain in the
frontend (only stream.type in VideoPlayer, deferred to phase 4). 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 src-tauri/src/domain/ as the single source of truth for the
media model, with all Jellyfin translation isolated in from_jellyfin.rs.
Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem
as additive, defaulted dual-carry alongside the legacy Jellyfin-named
fields, so nothing breaks while the frontend migrates off them.
- domain/media.rs: canonical MediaKind (closed enum, replaces stringly
item_type), Default = Other so unknown/defaulted items are inert.
- domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind
classification (all audited types + person subroles) and ticks->ms.
- MediaItem gains kind/duration_ms/image_id, populated at both mapping
seams (online to_media_item, offline cached_item_to_media_item) and
the synthesized-album/person sites.
- Regenerated bindings.ts: frontend now HAS the neutral model available.
Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour
change yet; wire shape is a superset of before.
Rust 456 tests, frontend 644 tests, check + check:boundary all green.
Add the "domain vocabulary lives in Rust" principle, the
check:boundary pre-commit gate, and a spec-writing section pointing at
the spec template and review checklist.
Add specs for the account menu, downloads-as-offline-library, offline
downloaded-only filter, and scoped search (+ boundary revision). Add the
new UR/DR entries to requirements.md, update ux-flows, and regenerate the
traceability matrix.
TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
Grep-based tripwire flagging multi-type includeItemTypes literals in the
Svelte frontend — the machine-detectable signature of the item-type
taxonomy leaking into presentation. Wire it into the Gitea build-and-test
workflow and a check:boundary package script. Motivated by
docs/specs/scoped-search-boundary.md.
Move account actions (Settings, Downloads, Display preferences, Sign
out) out of the library-only header into a shared AccountMenu anchored in
a global AppHeader, available on every authenticated non-immersive
screen. Add a layoutShell helper deciding where chrome shows, expose
serverName/serverUrl auth stores, and a display view-mode preference. The
settings page also gains the UR-053 WiFi-only toggle.
TRACES: UR-054 | DR-075, DR-076, DR-077
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
The connectivity store now drives the "downloaded only" view so an
offline library page shows just on-device media, with the server catalog
revealed only when "Show all server media" is toggled.
TRACES: UR-052 | DR-078, DR-079
Add a search scope (all/music/shows/movies) resolved from the entry
route and adjustable via filter chips, threaded through the library
store's search() into includeItemTypes. Results group by type in a
user-configurable order, editable from settings.
TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
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
traceability.yml duplicated the coverage check now owned by
traceability-check.yml, running the same extraction on every push and PR
to master/main/develop. Dead CI: nothing references it, and keeping both
doubled runner time for one result.
The gitea-pages push step used ${GITHUB_SHA::8}, a bash-only substring
expansion. The Gitea runner executes run: blocks with /bin/sh (dash),
which rejects it with "Bad substitution" and exits 2, failing the job
after the site had already built successfully.
Use cut(1) to shorten the SHA instead, which is POSIX sh compatible.
bun is already baked into the jellytau-builder image (Dockerfile.builder),
so oven-sh/setup-bun@v1 was redundant. Fetching that GitHub-hosted action
from the self-hosted Gitea runner hangs the job before any steps run.
Removed from traceability-check, traceability, and publish-docs workflows;
build-and-test and build-release never used it and never stalled.
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.
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
Needed to deploy over the CI-installed build on device: CI derives
versionCode as 1000 + major*10000 + minor*100 + patch, so the field is
already at 1000, while a local `tauri android build` writes the raw
patch number (15) and is rejected as a downgrade.
Cargo.toml is versioned independently (0.1.0) and is left alone.
Note: local builds still emit the raw code (16) - only CI applies the
1000+ formula, so deploying to a device with a CI build installed needs
gen/android/app/tauri.properties patched after Tauri regenerates it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add PiP for native (ExoPlayer) video on Android. Video renders into a
SurfaceView behind the WebView, so PiP is driven by the Activity shrinking
into a floating window rather than the HTML5 PiP API (which WebKitGTK does
not implement, hence Android-only).
- PictureInPictureManager.kt: enter PiP with the video's aspect ratio
(clamped to the 1:2.39-2.39:1 range Android accepts, outside which it
throws), plus a play/pause RemoteAction. Hides the WebView while in PiP -
it is opaque and sits above the surface, so it would otherwise occlude the
video entirely - and re-fits the surface on exit.
- MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged,
and an AndroidPictureInPicture JS interface following the existing
AndroidAudioFocus pattern.
- pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when
the native bridge reports support.
- proguard: keep rules for @JavascriptInterface methods, which are only
referenced from JS and would be stripped in minified release builds.
Casting needs no special handling: canEnterPip() checks natively that a
local video surface is attached and playing, which a remote session lacks.
While wiring the manifest, found that three tracked files under
src-tauri/android/ were never reaching any build. Gradle reads only
gen/android/app/src/main/, and sync-android-sources.sh did not copy them:
- src/main/AndroidManifest.xml was a partial <application> fragment written
as if Tauri merged it. It does not - there is no manifest-merger hook
here, so its hardwareAccelerated flag never reached an APK. Promoted to
the complete authoritative manifest (folding in that flag) and synced.
- src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows)
was never copied; the sync only globbed mipmap-*. Now synced.
- build.gradle.kts was a leftover com.android.library module config with
stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at
1.5.0. Deleted.
Verified: merged manifest now carries hardwareAccelerated,
supportsPictureInPicture, resizeableActivity and the density configChange;
themes.xml compiles into merged resources; Kotlin builds warning-free;
svelte-check clean; 537 frontend tests pass.
Not verified: PiP behaviour on a device, and the release keep rules against
a minified build. assembleUniversalDebug cannot complete in this
environment - the Rust step wants a dev-server addr file that only exists
under `tauri android dev`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runner executes workflow steps with /bin/sh (dash), which has no
here-strings: `IFS='.' read -r MAJ MIN PAT <<< "$VERSION"` failed with
"Syntax error: redirection unexpected" and aborted the Android release build.
Parse the semver with `cut` instead, drop the GNU-only `\s` from the sed
expression in favour of [[:space:]], and default any missing component to 0 so a
malformed version can never emit versionCode 0. Verified under sh:
0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000 (monotonic).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigation:
- Split conflated "back" into navigateUp (deterministic route parent) and a
history-safe navigateBack that tracks in-app depth via afterNavigate instead
of history.length. Fixes the resume-from-background trap where a stale WebView
stack left the header arrow stuck on the current page.
- /library self-corrects for music/tv/movies (which have dedicated landing
pages): a leftover currentLibrary no longer forces the inline content-list
view, so "up"/back shows the libraries overview. Live TV / channels / other
types still render inline.
Startup (unblock first paint):
- auth.initialize() no longer awaits security-status, player-config, or session
verification before flipping isInitialized. These run fire-and-forget after the
session is restored, so the library overview paints without waiting on several
serial IPC round-trips.
Versioning / CI:
- tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to
0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags).
- Release workflow now pins a monotonic Android versionCode
(1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade
below prior installs and always increase in semver order.
Tests: navigation (4), auth (29), playbackMode (23) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build-and-test.yml built a full APK on every master push without running
sync-android-sources.sh, so it used the wrong (Tauri-default) sources, was
unsigned, and duplicated the ~15min build that build-release.yml does properly
on tags. Replace it with cargo check --target aarch64-linux-android (~1min),
which catches Android Rust breakage without linking, bundling, or signing.
The signed release APK remains a tag-only artifact from build-release.yml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.
- Make MediaSessionCompat the single source of truth; route all transport
commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
session poller (works while the screen is locked, unlike WebView timers) via
a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
prev/seek to the remote Jellyfin session; Stop while casting emits
RemoteDisconnectRequested, which the frontend handles by transferring to local.
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 release-notes echo lines used unescaped backticks, which the shell ran
as command substitution; their output leaked control characters into
release_notes.md, so jq failed with 'Invalid string: control characters ...
must be escaped' when building the release payload.
- Escape the backticks so they are literal markdown.
- Remove emoji from the release-notes content (plain ASCII headings).
- Handle an already-existing release (HTTP 409) by reusing its id for
asset upload instead of failing.
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>
JellyTauPlayer.kt references com.dtourolle.jellytau.VideoOverlayManager,
but the file existed only in the gitignored gen/android dir, so it
survived locally but vanished in CI (which regenerates gen/android via
'tauri android init'). sync-android-sources.sh copies top-level *.kt
from src-tauri/android, so adding it there gets it synced into the build.
Fixes: 'Unresolved reference: VideoOverlayManager' in
:app:compileUniversalReleaseKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tauri CLI requires '--apk true'; bare '--apk' fails with
"a value is required for '--apk <APK>'". The release workflow
only reached this step now that checkout/container issues are fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Standardize on bun: remove package-lock.json, add packageManager field,
gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
and slim README down to a project intro + docs index
- Fix internal references to the moved files
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
src-tauri/.cargo/config.toml hardcoded absolute NDK paths under
/home/dtourolle, which only exist on the dev laptop. In CI this made
ring's build script fail to find aarch64-linux-android34-clang. Tauri
derives the linker/CC paths from NDK_HOME automatically during
'android build', so the file is unnecessary there. Untrack it and
gitignore it; local dev copies are preserved on disk.
Set ANDROID_HOME/NDK_HOME/NDK_VERSION at the job level and add a step
that installs the NDK via sdkmanager when missing, so the Android init
step no longer fails when the builder image lacks NDK_HOME.
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.
- 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.
Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState
(container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin
PascalCase still deserializes). Bindings and the existing frontend are camelCase
again — casting works with no frontend session changes.
Downloads: migrate DownloadButton.svelte and downloads.ts to the typed
commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request
objects), matching the bundled backend signatures.
Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
Tailwind v4 auto-scans all source files; the 3000-line generated bindings.ts has
no CSS classes and a large generated TS file can confuse class detection. Add an
@source not exclusion.
The tauri-specta `.export(...)` call ran inside run() on every debug app start
and tried to write `../src/lib/api/bindings.ts`, a path that doesn't exist on a
device — `.expect()` panicked at startup, crashing the app instantly on Android.
Bindings are generated by the `export_typescript_bindings` test instead.
- Extract specta_builder() so run() and a #[test] share one command list.
- Add export_typescript_bindings test that writes src/lib/api/bindings.ts.
- Configure the TS exporter bigint behavior to Number (matches existing frontend).
- Commit the generated bindings.ts (typed commands.* wrappers + all DTO types).
- 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.
- Move commands/player.rs to commands/player/mod.rs (folder module).
- Extract the 5 self-contained remote-session control commands
(remote_play_on_session/send_command/session_seek/set_volume/toggle_mute)
into commands/player/remote.rs, re-exported via `pub use remote::*` so the
command names remain at commands::player::* and the invoke_handler is unchanged.
No behavior change; establishes the submodule split pattern for the oversized
command file.
Move the pure determine_video_seek_strategy function and its VideoSeekStrategy
enum (plus the 5 seek-strategy unit tests) out of the command layer into
player/seek.rs, where they belong and are testable without the Tauri State
harness. commands/player.rs now imports them from crate::player. No behavior
change.
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
sites with _safe variants across 14 files, eliminating the player
crash-cascade class. Tokio async mutexes are unchanged.
Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
it falls back to NullBackend and emits a backend-init-failed event so the UI
can show "playback unavailable" instead of the app crashing. Fatal DB-setup
panics are kept.
Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
reflect reality (~20.5k non-test frontend) and document the events+polling
hybrid plus the new locking/backend-init behavior.
- Convert music category buttons from <button> to native <a> links for better Android compatibility
- Convert artist/album nested buttons in TrackList to <a> links to fix HTML validation issues
- Add event handlers with proper stopPropagation to maintain click behavior
- Increase library overview card sizes from medium to large (50% bigger)
- Increase thumbnail sizes in list view from 10x10 to 16x16
- Add console logging for debugging click events on mobile
- Remove preventDefault() handlers that were blocking Android touch events
These changes resolve navigation issues on Android devices where buttons weren't responding to taps. Native <a> links provide better cross-platform compatibility and allow SvelteKit to handle navigation more reliably.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Documentation for Rustdoc"><title>Help</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="./static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="./static.files/normalize-9960930a.css"><linkrel="stylesheet"href="./static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="./"data-static-root-path="./static.files/"data-current-crate="jellytau"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="./static.files/storage-41dd4d93.js"></script><scriptdefersrc="./static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="./static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="./static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="./static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod sys"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">All</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><aclass="logo-container"href="./index.html"><imgclass="rust-logo"src="./static.files/rust-logo-9a9549ea.svg"alt="logo"></a><h2><ahref="./index.html">Rustdoc</a><spanclass="version">1.97.1</span></h2></div><divclass="version">(8bab26f4f 2026-07-14)</div><h2class="location">Help</h2><divclass="sidebar-elems"></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><h1>Rustdoc help</h1><spanclass="out-of-band"><aid="back"href="javascript:void(0)"onclick="history.back();">Back</a></span></div><noscript><section><p>You need to enable JavaScript to use keyboard commands or search.</p><p>For more information, browse the <ahref="https://doc.rust-lang.org/1.97.1/rustdoc/">rustdoc handbook</a>.</p></section></noscript></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="List of all items in this crate"><title>List of all items in this crate</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../"data-static-root-path="../static.files/"data-current-crate="jellytau"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../static.files/storage-41dd4d93.js"></script><scriptdefersrc="../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod sys"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">All</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../jellytau/index.html">jellytau</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><sectionid="rustdoc-toc"><h3><ahref="#functions">Crate Items</a></h3><ulclass="block"><li><ahref="#functions"title="Functions">Functions</a></li></ul></section><divid="rustdoc-modnav"></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><h1>List of all items</h1><rustdoc-toolbar></rustdoc-toolbar></div><h3id="functions">Functions</h3><ulclass="all-items"><li><ahref="fn.main.html">main</a></li></ul></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="API documentation for the Rust `main` fn in crate `jellytau`."><title>main in jellytau - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../"data-static-root-path="../static.files/"data-current-crate="jellytau"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">main</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../jellytau/index.html">jellytau</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="index.html">jellytau</a></div><h1>Function <spanclass="fn">main</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../src/jellytau/main.rs.html#4-6">Source</a></span></div><preclass="rust item-decl"><code>pub(crate) fn main()</code></pre></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="API documentation for the Rust `jellytau` crate."><title>jellytau - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../"data-static-root-path="../static.files/"data-current-crate="jellytau"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../static.files/storage-41dd4d93.js"></script><scriptdefersrc="../crates.js"></script><scriptdefersrc="../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod crate"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">Crate jellytau</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../jellytau/index.html">jellytau</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><ulclass="block"><li><aid="all-types"href="all.html">All Items</a></li></ul><sectionid="rustdoc-toc"><h3><ahref="#functions">Crate Items</a></h3><ulclass="block"><li><ahref="#functions"title="Functions">Functions</a></li></ul></section><divid="rustdoc-modnav"></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><h1>Crate <span>jellytau</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../src/jellytau/main.rs.html#2-6">Source</a></span></div><h2id="functions"class="section-header">Functions<ahref="#functions"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="fn"href="fn.main.html"title="fn jellytau::main">main</a><spantitle="Restricted Visibility"> 🔒</span></dt></dl></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="API documentation for the Rust `VERIFICATION_INTERVAL_MS` constant in crate `jellytau_lib`."><title>VERIFICATION_INTERVAL_MS in jellytau_lib::auth::session_verifier - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">VERIFICATION_INTERVAL_MS</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>auth::<wbr>session_<wbr>verifier</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">auth</a>::<wbr><ahref="index.html">session_verifier</a></div><h1>Constant <spanclass="constant">VERIFICATION_<wbr>INTERVAL_<wbr>MS</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/auth/session_verifier.rs.html#10">Source</a></span></div><preclass="rust item-decl"><code>const VERIFICATION_INTERVAL_MS: <aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.u64.html">u64</a> = 300000;</code></pre></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="API documentation for the Rust `session_verifier` mod in crate `jellytau_lib`."><title>jellytau_lib::auth::session_verifier - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="../sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">Module session_verifier</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><sectionid="rustdoc-toc"><h2class="location"><ahref="#">Module session_<wbr>verifier</a></h2><h3><ahref="#structs">Module Items</a></h3><ulclass="block"><li><ahref="#structs"title="Structs">Structs</a></li><li><ahref="#enums"title="Enums">Enums</a></li><li><ahref="#constants"title="Constants">Constants</a></li></ul></section><divid="rustdoc-modnav"><h2><ahref="../index.html">In jellytau_<wbr>lib::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">auth</a></div><h1>Module <span>session_<wbr>verifier</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/auth/session_verifier.rs.html#1-170">Source</a></span></div><h2id="structs"class="section-header">Structs<ahref="#structs"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="struct"href="struct.SessionVerifier.html"title="struct jellytau_lib::auth::session_verifier::SessionVerifier">Session<wbr>Verifier</a></dt><dd>Background session verifier</dd></dl><h2id="enums"class="section-header">Enums<ahref="#enums"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="enum"href="enum.SessionVerificationEvent.html"title="enum jellytau_lib::auth::session_verifier::SessionVerificationEvent">Session<wbr>Verification<wbr>Event</a></dt><dd>Session verification result event emitted to frontend</dd></dl><h2id="constants"class="section-header">Constants<ahref="#constants"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="constant"href="constant.VERIFICATION_INTERVAL_MS.html"title="constant jellytau_lib::auth::session_verifier::VERIFICATION_INTERVAL_MS">VERIFICATION_<wbr>INTERVAL_<wbr>MS</a><spantitle="Restricted Visibility"> 🔒</span></dt></dl></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Connect to a Jellyfin server and get server info"><title>auth_connect_to_server in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_connect_to_server</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>connect_<wbr>to_<wbr>server</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#75-80">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_connect_to_server(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="struct"href="../../auth/struct.ServerInfo.html"title="struct jellytau_lib::auth::ServerInfo">ServerInfo</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Connect to a Jellyfin server and get server info</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Get current session"><title>auth_get_session in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_get_session</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>get_<wbr>session</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#171-175">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_get_session(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Initialize the auth manager (call on app startup) Restores session from storage if available"><title>auth_initialize in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_initialize</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>initialize</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#20-70">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_initialize(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Login with username and password"><title>auth_login in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_login</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>login</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#85-114">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_login(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Logout (clear session and call Jellyfin logout endpoint)"><title>auth_logout in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_logout</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>logout</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#142-166">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_logout(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Re-authenticate with password (when session expired)"><title>auth_reauthenticate in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_reauthenticate</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>reauthenticate</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#244-282">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_reauthenticate(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Set current session (for restoration from storage)"><title>auth_set_session in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_set_session</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>set_<wbr>session</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#180-195">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_set_session(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Start background session verification"><title>auth_start_verification in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_start_verification</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>start_<wbr>verification</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#200-224">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_start_verification(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Stop background session verification"><title>auth_stop_verification in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_stop_verification</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>stop_<wbr>verification</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#229-239">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_stop_verification(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Verify current session"><title>auth_verify_session in jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">auth_verify_session</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>auth</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">auth</a></div><h1>Function <spanclass="fn">auth_<wbr>verify_<wbr>session</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#119-137">Source</a></span></div><preclass="rust item-decl"><code>pub async fn auth_verify_session(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Authentication and session-lifecycle commands."><title>jellytau_lib::commands::auth - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="../sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">Module auth</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><sectionid="rustdoc-toc"><h2class="location"><ahref="#">Module auth</a></h2><h3><ahref="#structs">Module Items</a></h3><ulclass="block"><li><ahref="#structs"title="Structs">Structs</a></li><li><ahref="#functions"title="Functions">Functions</a></li></ul></section><divid="rustdoc-modnav"><h2><ahref="../index.html">In jellytau_<wbr>lib::<wbr>commands</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a></div><h1>Module <span>auth</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/auth.rs.html#1-476">Source</a></span></div><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Authentication and session-lifecycle commands.</p>
</div></details><h2id="structs"class="section-header">Structs<ahref="#structs"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="struct"href="struct.AuthManagerWrapper.html"title="struct jellytau_lib::commands::auth::AuthManagerWrapper">Auth<wbr>Manager<wbr>Wrapper</a></dt><dd>Wrapper for AuthManager to manage in Tauri state</dd><dt><aclass="struct"href="struct.SessionVerifierWrapper.html"title="struct jellytau_lib::commands::auth::SessionVerifierWrapper">Session<wbr>Verifier<wbr>Wrapper</a></dt><dd>Wrapper for SessionVerifier to manage in Tauri state</dd></dl><h2id="functions"class="section-header">Functions<ahref="#functions"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="fn"href="fn.auth_connect_to_server.html"title="fn jellytau_lib::commands::auth::auth_connect_to_server">auth_<wbr>connect_<wbr>to_<wbr>server</a></dt><dd>Connect to a Jellyfin server and get server info</dd><dt><aclass="fn"href="fn.auth_get_session.html"title="fn jellytau_lib::commands::auth::auth_get_session">auth_<wbr>get_<wbr>session</a></dt><dd>Get current session</dd><dt><aclass="fn"href="fn.auth_initialize.html"title="fn jellytau_lib::commands::auth::auth_initialize">auth_<wbr>initialize</a></dt><dd>Initialize the auth manager (call on app startup)
Restores session from storage if available</dd><dt><aclass="fn"href="fn.auth_login.html"title="fn jellytau_lib::commands::auth::auth_login">auth_<wbr>login</a></dt><dd>Login with username and password</dd><dt><aclass="fn"href="fn.auth_logout.html"title="fn jellytau_lib::commands::auth::auth_logout">auth_<wbr>logout</a></dt><dd>Logout (clear session and call Jellyfin logout endpoint)</dd><dt><aclass="fn"href="fn.auth_reauthenticate.html"title="fn jellytau_lib::commands::auth::auth_reauthenticate">auth_<wbr>reauthenticate</a></dt><dd>Re-authenticate with password (when session expired)</dd><dt><aclass="fn"href="fn.auth_set_session.html"title="fn jellytau_lib::commands::auth::auth_set_session">auth_<wbr>set_<wbr>session</a></dt><dd>Set current session (for restoration from storage)</dd><dt><aclass="fn"href="fn.auth_start_verification.html"title="fn jellytau_lib::commands::auth::auth_start_verification">auth_<wbr>start_<wbr>verification</a></dt><dd>Start background session verification</dd><dt><aclass="fn"href="fn.auth_stop_verification.html"title="fn jellytau_lib::commands::auth::auth_stop_verification">auth_<wbr>stop_<wbr>verification</a></dt><dd>Stop background session verification</dd><dt><aclass="fn"href="fn.auth_verify_session.html"title="fn jellytau_lib::commands::auth::auth_verify_session">auth_<wbr>verify_<wbr>session</a></dt><dd>Verify current session</dd></dl></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Kebab-case, per the project’s event convention."><title>CATALOG_INDEX_EVENT in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">CATALOG_INDEX_EVENT</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">CATALOG_<wbr>INDEX_<wbr>EVENT</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#57">Source</a></span></div><preclass="rust item-decl"><code>pub const CATALOG_INDEX_EVENT: &<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.str.html">str</a> = "catalog-index-event";</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Kebab-case, per the project’s event convention.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Delay before the first staleness check, to let sign-in complete and the repository be registered. Without it the first check runs against an empty repository manager and a fresh install would sit unindexed until the next tick."><title>CATALOG_INDEX_FIRST_CHECK in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">CATALOG_INDEX_FIRST_CHECK</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">CATALOG_<wbr>INDEX_<wbr>FIRST_<wbr>CHECK</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#54">Source</a></span></div><preclass="rust item-decl"><code>const CATALOG_INDEX_FIRST_CHECK: <aclass="struct"href="https://doc.rust-lang.org/1.97.1/core/time/struct.Duration.html"title="struct core::time::Duration">Duration</a>;</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Delay before the first staleness check, to let sign-in complete and the
repository be registered. Without it the first check runs against an empty
repository manager and a fresh install would sit unindexed until the next
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="How often the scheduler wakes to check staleness. Far shorter than the TTL because a tick is nearly free — one indexed `app_settings` lookup — and it is what makes the indexer responsive to events it cannot subscribe to: signing in, and coming back online. The TTL, not the tick, decides whether a crawl actually happens."><title>CATALOG_INDEX_TICK in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">CATALOG_INDEX_TICK</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">CATALOG_<wbr>INDEX_<wbr>TICK</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#48">Source</a></span></div><preclass="rust item-decl"><code>const CATALOG_INDEX_TICK: <aclass="struct"href="https://doc.rust-lang.org/1.97.1/core/time/struct.Duration.html"title="struct core::time::Duration">Duration</a>;</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>How often the scheduler wakes to <em>check</em> staleness. Far shorter than the TTL
because a tick is nearly free — one indexed <code>app_settings</code> lookup — and it is
what makes the indexer responsive to events it cannot subscribe to: signing
in, and coming back online. The TTL, not the tick, decides whether a crawl
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="How long an index stays fresh before a re-index is due."><title>CATALOG_INDEX_TTL in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">CATALOG_INDEX_TTL</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">CATALOG_<wbr>INDEX_<wbr>TTL</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#41">Source</a></span></div><preclass="rust item-decl"><code>const CATALOG_INDEX_TTL: <aclass="struct"href="https://doc.rust-lang.org/1.97.1/core/time/struct.Duration.html"title="struct core::time::Duration">Duration</a>;</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>How long an index stays fresh before a re-index is due.</p>
<p>This lives in Rust rather than being a frontend constant because it decides
<em>whether the local cache is authoritative</em> — the same class of decision as
<code>include_catalog_browse</code>, and squarely the “sync policy” the spec review
checklist keeps out of the presentation layer. If it later becomes
user-configurable it stays a Rust-owned setting edited through a command.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Item types worth caching for offline browsing: containers the library landing pages render plus the playable leaves users queue for download. `MusicArtist` and `Playlist` are here because search groups results by them (UR-060’s Artists group). Without them in the crawl, the local index can never answer an artist query and those groups can only ever be filled by the server leg. Keep this in step with what `prune_stale_catalog` is allowed to sweep — the crawl is only authoritative for the types it asks for."><title>CATALOG_ITEM_TYPES in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">CATALOG_ITEM_TYPES</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">CATALOG_<wbr>ITEM_<wbr>TYPES</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#97-107">Source</a></span></div><preclass="rust item-decl"><code>const CATALOG_ITEM_TYPES: &[&<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.str.html">str</a>];</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Item types worth caching for offline browsing: containers the library
landing pages render plus the playable leaves users queue for download.
<code>MusicArtist</code> and <code>Playlist</code> are here because search groups results by them
(UR-060’s Artists group). Without them in the crawl, the local index can
never answer an artist query and those groups can only ever be filled by the
server leg. Keep this in step with what <code>prune_stale_catalog</code> is allowed to
sweep — the crawl is only authoritative for the types it asks for.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="app_settings key holding the RFC-3339 timestamp of the last successful full-catalog sync."><title>LAST_CATALOG_SYNC_KEY in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">LAST_CATALOG_SYNC_KEY</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">LAST_<wbr>CATALOG_<wbr>SYNC_<wbr>KEY</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#32">Source</a></span></div><preclass="rust item-decl"><code>const LAST_CATALOG_SYNC_KEY: &<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.str.html">str</a> = "last_catalog_sync";</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>app_settings key holding the RFC-3339 timestamp of the last successful
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Jellyfin item types whose download is a video stream rather than an audio one. The download queue stores an opaque `media_type` (‘audio’/‘video’); this is where the taxonomy that produces it lives, so the frontend never has to know which item types are video."><title>VIDEO_ITEM_TYPES in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc constant"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">VIDEO_ITEM_TYPES</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Constant <spanclass="constant">VIDEO_<wbr>ITEM_<wbr>TYPES</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#115">Source</a></span></div><preclass="rust item-decl"><code>const VIDEO_ITEM_TYPES: &[&<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.str.html">str</a>];</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Jellyfin item types whose download is a <em>video</em> stream rather than an audio
one. The download queue stores an opaque <code>media_type</code> (‘audio’/‘video’); this
is where the taxonomy that produces it lives, so the frontend never has to
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Report the last-synced timestamp so the UI can show a hint / decide whether to trigger a fresh sync."><title>catalog_sync_status in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">catalog_sync_status</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">catalog_<wbr>sync_<wbr>status</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#432-450">Source</a></span></div><preclass="rust item-decl"><code>pub async fn catalog_sync_status(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="struct"href="struct.CatalogSyncStatus.html"title="struct jellytau_lib::commands::catalog::CatalogSyncStatus">CatalogSyncStatus</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Report the last-synced timestamp so the UI can show a hint / decide whether
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Whether an index pass is due, given when one last completed."><title>index_is_due in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">index_is_due</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">index_<wbr>is_<wbr>due</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#282-299">Source</a></span></div><preclass="rust item-decl"><code>pub(crate) fn index_is_due(
) -><aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.bool.html">bool</a></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Whether an index pass is due, given when one last completed.</p>
<p>Pure so the policy is unit-testable without a clock, a server, or a database.
<code>None</code> (never indexed) and an unparseable stored value both mean “due” — a
corrupt timestamp should trigger a re-index, not silently freeze the catalog.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="One scheduler tick: check the preconditions, then index if due."><title>maybe_run_scheduled_pass in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">maybe_run_scheduled_pass</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">maybe_<wbr>run_<wbr>scheduled_<wbr>pass</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#347-426">Source</a></span></div><preclass="rust item-decl"><code>async fn maybe_run_scheduled_pass(app: &AppHandle) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.unit.html">()</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>One scheduler tick: check the preconditions, then index if due.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Read the last-sync timestamp straight from `app_settings`."><title>read_last_sync in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">read_last_sync</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">read_<wbr>last_<wbr>sync</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#302-316">Source</a></span></div><preclass="rust item-decl"><code>async fn read_last_sync(db_service: &<aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/sync/struct.Arc.html"title="struct alloc::sync::Arc">Arc</a><<aclass="struct"href="../../storage/db_service/struct.RusqliteService.html"title="struct jellytau_lib::storage::db_service::RusqliteService">RusqliteService</a>>) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/option/enum.Option.html"title="enum core::option::Option">Option</a><<aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Read the last-sync timestamp straight from <code>app_settings</code>.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Requeue video downloads that were fetched as audio."><title>requeue_mistyped_video_downloads in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">requeue_mistyped_video_downloads</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">requeue_<wbr>mistyped_<wbr>video_<wbr>downloads</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#491-518">Source</a></span></div><preclass="rust item-decl"><code>pub(crate) async fn requeue_mistyped_video_downloads(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.usize.html">usize</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Requeue video downloads that were fetched as audio.</p>
<p>Before <ahref="fn.resolve_pending_download_urls.html"title="fn jellytau_lib::commands::catalog::resolve_pending_download_urls"><code>resolve_pending_download_urls</code></a> consulted the item’s type, a row
with no <code>media_type</code> — which is every row queued from a media card, since
<code>download_item</code> does not record one — resolved against
<code>get_audio_stream_url</code>. A movie queued that way completed with an audio-only
transcode on disk, so playing it offline could only ever fail. Those rows are
identifiable after the fact (no <code>media_type</code>, but a video item), so reset them
to pending with no URL and let the resolver fetch the real video.</p>
<p>Rows carrying an explicit <code>media_type</code> were resolved correctly and are left
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Core of `resume_queued_downloads`, factored out for testing: select every `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. The `resolve` closure receives `(item_id, media_type, quality_preset)`."><title>resolve_pending_download_urls in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">resolve_pending_download_urls</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">resolve_<wbr>pending_<wbr>download_<wbr>urls</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#529-636">Source</a></span></div><preclass="rust item-decl"><code>pub(crate) async fn resolve_pending_download_urls<F, Fut>(
Fut: <aclass="trait"href="https://doc.rust-lang.org/1.97.1/core/future/future/trait.Future.html"title="trait core::future::future::Future">Future</a><Output = <aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/option/enum.Option.html"title="enum core::option::Option">Option</a><<aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>>>,</div></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Core of <ahref="fn.resume_queued_downloads.html"title="fn jellytau_lib::commands::catalog::resume_queued_downloads"><code>resume_queued_downloads</code></a>, factored out for testing: select every
<code>pending</code>/<code>stream_url IS NULL</code> row, resolve each via <code>resolve</code> (returning
<code>None</code> leaves the row pending), and heal the row so the pump can start it.
The <code>resolve</code> closure receives <code>(item_id, media_type, quality_preset)</code>.</p>
<p><code>only_ids</code> restricts the sweep to specific download rows. Reconnect passes
<code>None</code> 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
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Resolve the stream URL for every download row that was queued while offline (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they start. Call this on reconnect."><title>resume_queued_downloads in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">resume_queued_downloads</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">resume_<wbr>queued_<wbr>downloads</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#648-751">Source</a></span></div><preclass="rust item-decl"><code>pub async fn resume_queued_downloads(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="struct"href="struct.ResumeQueuedResult.html"title="struct jellytau_lib::commands::catalog::ResumeQueuedResult">ResumeQueuedResult</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Resolve the stream URL for every download row that was queued while offline
(<code>status = 'pending' AND stream_url IS NULL</code>), then pump the queue so they
start. Call this on reconnect.</p>
<p>Audio rows resolve via <code>get_audio_stream_url</code>; video rows (media_type =
‘video’) via the pure <code>get_video_download_url</code> builder using the row’s stored
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="One full-catalog indexing pass, shared by the `sync_full_catalog` command and the background scheduler (DR-109) so there is exactly one implementation and one concurrency guard."><title>run_index_pass in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">run_index_pass</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">run_<wbr>index_<wbr>pass</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#165-273">Source</a></span></div><preclass="rust item-decl"><code>pub(crate) async fn run_index_pass(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Control whether offline library queries reveal the full synced catalog (greyed-out, non-downloaded media) or only downloaded/local media."><title>set_show_server_catalog in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">set_show_server_catalog</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">set_<wbr>show_<wbr>server_<wbr>catalog</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#462-464">Source</a></span></div><preclass="rust item-decl"><code>pub fn set_show_server_catalog(show: <aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.bool.html">bool</a>)</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Control whether offline library queries reveal the full synced catalog
(greyed-out, non-downloaded media) or only downloaded/local media.</p>
<p>The frontend calls this from the “Show all server media” toggle: pass <code>true</code>
when online, or when offline with the toggle on; pass <code>false</code> when offline
with the toggle off so library pages show downloaded media only. Fixes the
bug where offline library pages showed every server item regardless of the
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Start the background catalog indexer."><title>spawn_catalog_indexer in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">spawn_catalog_indexer</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">spawn_<wbr>catalog_<wbr>indexer</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#328-344">Source</a></span></div><preclass="rust item-decl"><code>pub fn spawn_catalog_indexer(app: AppHandle)</code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Start the background catalog indexer.</p>
<p>Replaces the frontend’s startup-only <code>syncCatalog()</code> call: index freshness is
sync policy and belongs in Rust (see the layer assignment in
docs/architecture/03-data-flow.md, “Search Flow”). Ticks every
<ahref="constant.CATALOG_INDEX_TICK.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_TICK"><code>CATALOG_INDEX_TICK</code></a> and
runs a pass when a repository exists, the server is reachable, and the index
is older than <ahref="constant.CATALOG_INDEX_TTL.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_TTL"><code>CATALOG_INDEX_TTL</code></a>.</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Walk every library on the server and persist all items to the offline cache so the full catalog is browsable offline (greyed out when not downloaded)."><title>sync_full_catalog in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">sync_full_catalog</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Function <spanclass="fn">sync_<wbr>full_<wbr>catalog</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#145-158">Source</a></span></div><preclass="rust item-decl"><code>pub async fn sync_full_catalog(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="struct"href="struct.CatalogSyncResult.html"title="struct jellytau_lib::commands::catalog::CatalogSyncResult">CatalogSyncResult</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Walk every library on the server and persist all items to the offline cache
so the full catalog is browsable offline (greyed out when not downloaded).</p>
<p>Best-effort: a library that fails to fetch is counted and skipped rather than
aborting the whole sync. Runs libraries sequentially to avoid hammering the
server. Uses <code>Recursive=true</code> so a single request per library returns the
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Tauri commands for the offline “browse & queue” feature."><title>jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="../sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc mod"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">Module catalog</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><sectionid="rustdoc-toc"><h2class="location"><ahref="#">Module catalog</a></h2><h3><ahref="#structs">Module Items</a></h3><ulclass="block"><li><ahref="#structs"title="Structs">Structs</a></li><li><ahref="#constants"title="Constants">Constants</a></li><li><ahref="#statics"title="Statics">Statics</a></li><li><ahref="#functions"title="Functions">Functions</a></li></ul></section><divid="rustdoc-modnav"><h2><ahref="../index.html">In jellytau_<wbr>lib::<wbr>commands</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a></div><h1>Module <span>catalog</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#1-1147">Source</a></span></div><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Tauri commands for the offline “browse & queue” feature.</p>
<p>Two backend pieces support browsing the full server catalog while offline
and queueing downloads that fire on reconnect:</p>
<ul>
<li><ahref="fn.sync_full_catalog.html"title="fn jellytau_lib::commands::catalog::sync_full_catalog"><code>sync_full_catalog</code></a> walks every library while online and persists all
items to the offline cache so the whole catalog is browsable (greyed out)
offline. It reuses [<code>HybridRepository::cache_items_from_server</code>], which in
turn reuses <code>OfflineRepository::save_to_cache</code> (sets <code>synced_at</code>, which is
what <code>get_items</code> branch 3 serves offline).</li>
<li><ahref="fn.resume_queued_downloads.html"title="fn jellytau_lib::commands::catalog::resume_queued_downloads"><code>resume_queued_downloads</code></a> resolves and pumps the <code>pending</code> download rows
that were queued offline (they have <code>stream_url IS NULL</code>), mirroring the
heal-and-pump pattern in <code>player_preload_upcoming</code>.</li>
</ul>
</div></details><h2id="structs"class="section-header">Structs<ahref="#structs"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="struct"href="struct.CatalogIndexEvent.html"title="struct jellytau_lib::commands::catalog::CatalogIndexEvent">Catalog<wbr>Index<wbr>Event</a></dt><dd>Progress of a background index pass, for the staleness hint in the UI.</dd><dt><aclass="struct"href="struct.CatalogSyncResult.html"title="struct jellytau_lib::commands::catalog::CatalogSyncResult">Catalog<wbr>Sync<wbr>Result</a></dt><dt><aclass="struct"href="struct.CatalogSyncStatus.html"title="struct jellytau_lib::commands::catalog::CatalogSyncStatus">Catalog<wbr>Sync<wbr>Status</a></dt><dt><aclass="struct"href="struct.IndexPassGuard.html"title="struct jellytau_lib::commands::catalog::IndexPassGuard">Index<wbr>Pass<wbr>Guard</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Clears <ahref="static.INDEX_IN_PROGRESS.html"title="static jellytau_lib::commands::catalog::INDEX_IN_PROGRESS"><code>INDEX_IN_PROGRESS</code></a> however the pass leaves — including on the <code>?</code>
early return when <code>get_libraries</code> fails, which a plain store at the end of
the function would leak.</dd><dt><aclass="struct"href="struct.ResumeQueuedResult.html"title="struct jellytau_lib::commands::catalog::ResumeQueuedResult">Resume<wbr>Queued<wbr>Result</a></dt></dl><h2id="constants"class="section-header">Constants<ahref="#constants"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="constant"href="constant.CATALOG_INDEX_EVENT.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_EVENT">CATALOG_<wbr>INDEX_<wbr>EVENT</a></dt><dd>Kebab-case, per the project’s event convention.</dd><dt><aclass="constant"href="constant.CATALOG_INDEX_FIRST_CHECK.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_FIRST_CHECK">CATALOG_<wbr>INDEX_<wbr>FIRST_<wbr>CHECK</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Delay before the first staleness check, to let sign-in complete and the
repository be registered. Without it the first check runs against an empty
repository manager and a fresh install would sit unindexed until the next
tick.</dd><dt><aclass="constant"href="constant.CATALOG_INDEX_TICK.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_TICK">CATALOG_<wbr>INDEX_<wbr>TICK</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>How often the scheduler wakes to <em>check</em> staleness. Far shorter than the TTL
because a tick is nearly free — one indexed <code>app_settings</code> lookup — and it is
what makes the indexer responsive to events it cannot subscribe to: signing
in, and coming back online. The TTL, not the tick, decides whether a crawl
actually happens.</dd><dt><aclass="constant"href="constant.CATALOG_INDEX_TTL.html"title="constant jellytau_lib::commands::catalog::CATALOG_INDEX_TTL">CATALOG_<wbr>INDEX_<wbr>TTL</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>How long an index stays fresh before a re-index is due.</dd><dt><aclass="constant"href="constant.CATALOG_ITEM_TYPES.html"title="constant jellytau_lib::commands::catalog::CATALOG_ITEM_TYPES">CATALOG_<wbr>ITEM_<wbr>TYPES</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Item types worth caching for offline browsing: containers the library
landing pages render plus the playable leaves users queue for download.
<code>MusicArtist</code> and <code>Playlist</code> are here because search groups results by them
(UR-060’s Artists group). Without them in the crawl, the local index can
never answer an artist query and those groups can only ever be filled by the
server leg. Keep this in step with what <code>prune_stale_catalog</code> is allowed to
sweep — the crawl is only authoritative for the types it asks for.</dd><dt><aclass="constant"href="constant.LAST_CATALOG_SYNC_KEY.html"title="constant jellytau_lib::commands::catalog::LAST_CATALOG_SYNC_KEY">LAST_<wbr>CATALOG_<wbr>SYNC_<wbr>KEY</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>app_settings key holding the RFC-3339 timestamp of the last successful
full-catalog sync.</dd><dt><aclass="constant"href="constant.VIDEO_ITEM_TYPES.html"title="constant jellytau_lib::commands::catalog::VIDEO_ITEM_TYPES">VIDEO_<wbr>ITEM_<wbr>TYPES</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Jellyfin item types whose download is a <em>video</em> stream rather than an audio
one. The download queue stores an opaque <code>media_type</code> (‘audio’/‘video’); this
is where the taxonomy that produces it lives, so the frontend never has to
know which item types are video.</dd></dl><h2id="statics"class="section-header">Statics<ahref="#statics"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="static"href="static.INDEX_IN_PROGRESS.html"title="static jellytau_lib::commands::catalog::INDEX_IN_PROGRESS">INDEX_<wbr>IN_<wbr>PROGRESS</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Guards against two passes running at once. Replaces the frontend’s
<code>syncInProgress</code> boolean in <code>offlineCatalog.ts</code>, which could not see a pass
started by the scheduler.</dd></dl><h2id="functions"class="section-header">Functions<ahref="#functions"class="anchor">§</a></h2><dlclass="item-table"><dt><aclass="fn"href="fn.catalog_sync_status.html"title="fn jellytau_lib::commands::catalog::catalog_sync_status">catalog_<wbr>sync_<wbr>status</a></dt><dd>Report the last-synced timestamp so the UI can show a hint / decide whether
to trigger a fresh sync.</dd><dt><aclass="fn"href="fn.index_is_due.html"title="fn jellytau_lib::commands::catalog::index_is_due">index_<wbr>is_<wbr>due</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Whether an index pass is due, given when one last completed.</dd><dt><aclass="fn"href="fn.maybe_run_scheduled_pass.html"title="fn jellytau_lib::commands::catalog::maybe_run_scheduled_pass">maybe_<wbr>run_<wbr>scheduled_<wbr>pass</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>One scheduler tick: check the preconditions, then index if due.</dd><dt><aclass="fn"href="fn.read_last_sync.html"title="fn jellytau_lib::commands::catalog::read_last_sync">read_<wbr>last_<wbr>sync</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Read the last-sync timestamp straight from <code>app_settings</code>.</dd><dt><aclass="fn"href="fn.requeue_mistyped_video_downloads.html"title="fn jellytau_lib::commands::catalog::requeue_mistyped_video_downloads">requeue_<wbr>mistyped_<wbr>video_<wbr>downloads</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Requeue video downloads that were fetched as audio.</dd><dt><aclass="fn"href="fn.resolve_pending_download_urls.html"title="fn jellytau_lib::commands::catalog::resolve_pending_download_urls">resolve_<wbr>pending_<wbr>download_<wbr>urls</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>Core of <ahref="fn.resume_queued_downloads.html"title="fn jellytau_lib::commands::catalog::resume_queued_downloads"><code>resume_queued_downloads</code></a>, factored out for testing: select every
<code>pending</code>/<code>stream_url IS NULL</code> row, resolve each via <code>resolve</code> (returning
<code>None</code> leaves the row pending), and heal the row so the pump can start it.
The <code>resolve</code> closure receives <code>(item_id, media_type, quality_preset)</code>.</dd><dt><aclass="fn"href="fn.resume_queued_downloads.html"title="fn jellytau_lib::commands::catalog::resume_queued_downloads">resume_<wbr>queued_<wbr>downloads</a></dt><dd>Resolve the stream URL for every download row that was queued while offline
(<code>status = 'pending' AND stream_url IS NULL</code>), then pump the queue so they
start. Call this on reconnect.</dd><dt><aclass="fn"href="fn.run_index_pass.html"title="fn jellytau_lib::commands::catalog::run_index_pass">run_<wbr>index_<wbr>pass</a><spantitle="Restricted Visibility"> 🔒</span></dt><dd>One full-catalog indexing pass, shared by the <ahref="fn.sync_full_catalog.html"title="fn jellytau_lib::commands::catalog::sync_full_catalog"><code>sync_full_catalog</code></a> command
and the background scheduler (DR-109) so there is exactly one implementation
and one concurrency guard.</dd><dt><aclass="fn"href="fn.set_show_server_catalog.html"title="fn jellytau_lib::commands::catalog::set_show_server_catalog">set_<wbr>show_<wbr>server_<wbr>catalog</a></dt><dd>Control whether offline library queries reveal the full synced catalog
(greyed-out, non-downloaded media) or only downloaded/local media.</dd><dt><aclass="fn"href="fn.spawn_catalog_indexer.html"title="fn jellytau_lib::commands::catalog::spawn_catalog_indexer">spawn_<wbr>catalog_<wbr>indexer</a></dt><dd>Start the background catalog indexer.</dd><dt><aclass="fn"href="fn.sync_full_catalog.html"title="fn jellytau_lib::commands::catalog::sync_full_catalog">sync_<wbr>full_<wbr>catalog</a></dt><dd>Walk every library on the server and persist all items to the offline cache
so the full catalog is browsable offline (greyed out when not downloaded).</dd></dl></section></div></main></body></html>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Guards against two passes running at once. Replaces the frontend’s `syncInProgress` boolean in `offlineCatalog.ts`, which could not see a pass started by the scheduler."><title>INDEX_IN_PROGRESS in jellytau_lib::commands::catalog - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc static"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">INDEX_IN_PROGRESS</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>catalog</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">catalog</a></div><h1>Static <spanclass="static">INDEX_<wbr>IN_<wbr>PROGRESS</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/catalog.rs.html#62">Source</a></span></div><preclass="rust item-decl"><code>static INDEX_IN_PROGRESS: <aclass="type"href="https://doc.rust-lang.org/1.97.1/core/sync/atomic/type.AtomicBool.html"title="type core::sync::atomic::AtomicBool">AtomicBool</a></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Guards against two passes running at once. Replaces the frontend’s
<code>syncInProgress</code> boolean in <code>offlineCatalog.ts</code>, which could not see a pass
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Check if the server is currently reachable"><title>connectivity_check_server in jellytau_lib::commands::connectivity - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">connectivity_check_server</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>connectivity</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">connectivity</a></div><h1>Function <spanclass="fn">connectivity_<wbr>check_<wbr>server</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/connectivity.rs.html#15-20">Source</a></span></div><preclass="rust item-decl"><code>pub async fn connectivity_check_server(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.bool.html">bool</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Check if the server is currently reachable</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Get the current connectivity status"><title>connectivity_get_status in jellytau_lib::commands::connectivity - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">connectivity_get_status</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>connectivity</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">connectivity</a></div><h1>Function <spanclass="fn">connectivity_<wbr>get_<wbr>status</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/connectivity.rs.html#37-42">Source</a></span></div><preclass="rust item-decl"><code>pub async fn connectivity_get_status(
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Mark the server as reachable (called after successful API calls)"><title>connectivity_mark_reachable in jellytau_lib::commands::connectivity - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">connectivity_mark_reachable</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>connectivity</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">connectivity</a></div><h1>Function <spanclass="fn">connectivity_<wbr>mark_<wbr>reachable</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/connectivity.rs.html#69-75">Source</a></span></div><preclass="rust item-decl"><code>pub async fn connectivity_mark_reachable(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.unit.html">()</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Mark the server as reachable (called after successful API calls)</p>
<!DOCTYPE html><htmllang="en"><head><metacharset="utf-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="generator"content="rustdoc"><metaname="description"content="Mark the server as unreachable (called after failed API calls)"><title>connectivity_mark_unreachable in jellytau_lib::commands::connectivity - Rust</title><script>if(window.location.protocol!=="file:")document.head.insertAdjacentHTML("beforeend","SourceSerif4-Regular-6b053e98.ttf.woff2,FiraSans-Italic-81dc35de.woff2,FiraSans-Regular-0fe48ade.woff2,FiraSans-MediumItalic-ccf7e434.woff2,FiraSans-Medium-e1aa3f0a.woff2,SourceCodePro-Regular-8badfe75.ttf.woff2,SourceCodePro-Semibold-aa29a496.ttf.woff2".split(",").map(f=>`<link rel="preload" as="font" type="font/woff2"href="../../../static.files/${f}">`).join(""))</script><linkrel="stylesheet"href="../../../static.files/normalize-9960930a.css"><linkrel="stylesheet"href="../../../static.files/rustdoc-17e0aaed.css"><metaname="rustdoc-vars"data-root-path="../../../"data-static-root-path="../../../static.files/"data-current-crate="jellytau_lib"data-themes=""data-resource-suffix=""data-rustdoc-version="1.97.1 (8bab26f4f 2026-07-14)"data-channel="1.97.1"data-search-js="search-fd9372ac.js"data-stringdex-js="stringdex-2da4960a.js"data-settings-js="settings-170eb4bf.js"><scriptsrc="../../../static.files/storage-41dd4d93.js"></script><scriptdefersrc="sidebar-items.js"></script><scriptdefersrc="../../../static.files/main-fcd733ba.js"></script><noscript><linkrel="stylesheet"href="../../../static.files/noscript-f7c3ffd8.css"></noscript><linkrel="alternate icon"type="image/png"href="../../../static.files/favicon-32x32-eab170b8.png"><linkrel="icon"type="image/svg+xml"href="../../../static.files/favicon-044be391.svg"></head><bodyclass="rustdoc fn"><aclass="skip-main-content"href="#main-content">Skip to main content</a><!--[if lte IE 11]><div class="warning">This old browser is unsupported and will most likely display funky things.</div><![endif]--><rustdoc-topbar><h2><ahref="#">connectivity_mark_unreachable</a></h2></rustdoc-topbar><navclass="sidebar"><divclass="sidebar-crate"><h2><ahref="../../../jellytau_lib/index.html">jellytau_<wbr>lib</a><spanclass="version">0.11.2</span></h2></div><divclass="sidebar-elems"><divid="rustdoc-modnav"><h2><ahref="index.html">In jellytau_<wbr>lib::<wbr>commands::<wbr>connectivity</a></h2></div></div></nav><divclass="sidebar-resizer"title="Drag to resize sidebar"></div><main><divclass="width-limiter"><sectionid="main-content"class="content"tabindex="-1"><divclass="main-heading"><divclass="rustdoc-breadcrumbs"><ahref="../../index.html">jellytau_lib</a>::<wbr><ahref="../index.html">commands</a>::<wbr><ahref="index.html">connectivity</a></div><h1>Function <spanclass="fn">connectivity_<wbr>mark_<wbr>unreachable</span> <buttonid="copy-path"title="Copy item path to clipboard">Copy item path</button></h1><rustdoc-toolbar></rustdoc-toolbar><spanclass="sub-heading"><aclass="src"href="../../../src/jellytau_lib/commands/connectivity.rs.html#80-87">Source</a></span></div><preclass="rust item-decl"><code>pub async fn connectivity_mark_unreachable(
) -><aclass="enum"href="https://doc.rust-lang.org/1.97.1/core/result/enum.Result.html"title="enum core::result::Result">Result</a><<aclass="primitive"href="https://doc.rust-lang.org/1.97.1/std/primitive.unit.html">()</a>, <aclass="struct"href="https://doc.rust-lang.org/1.97.1/alloc/string/struct.String.html"title="struct alloc::string::String">String</a>></code></pre><detailsclass="toggle top-doc"open><summaryclass="hideme"><span>Expand description</span></summary><divclass="docblock"><p>Mark the server as unreachable (called after failed API calls)</p>
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.