Compare commits

...
43 Commits
Author SHA1 Message Date
dtourolle 514e42fccb Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
2026-07-23 22:18:59 +02:00
dtourolle 9b1c9b3c91 feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
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).
2026-07-23 22:18:37 +02:00
dtourolle 1780109fb1 domain: flip last missed catalog .type reader (movies page) to .kind
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.
2026-07-23 22:15:15 +02:00
dtourolle 3a18ad060b domain: delete dead jellyfinFieldMapping; scope playbackUnits to session boundary (phase 4e)
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.
2026-07-23 22:13:54 +02:00
dtourolle 1968c06172 domain: neutral StreamKind for media streams (phase 4d)
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.
2026-07-23 22:11:31 +02:00
dtourolle ec8a7610f5 domain: player/reporting ticks -> milliseconds (phase 4c)
Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
2026-07-23 22:02:29 +02:00
dtourolle 93d198ce21 domain: primaryImageTag -> imageId end-to-end (phase 4a/4b)
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.
2026-07-23 21:40:58 +02:00
dtourolle 2b42b74912 domain: replace user-facing Jellyfin type badge with kind label (phase 3b)
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.
2026-07-23 21:31:51 +02:00
dtourolle 7660a33dfc domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)
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.
2026-07-23 21:30:04 +02:00
dtourolle 3e962a202c test: update playerVisibility fixtures to .kind (phase 2a)
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.
2026-07-23 21:16:42 +02:00
dtourolle 43ddc5a889 domain: flip isVideoItem in player store to .kind (phase 2a completion)
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.
2026-07-23 21:14:45 +02:00
dtourolle 772e9ca6d5 domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)
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.
2026-07-23 21:12:20 +02:00
dtourolle 55fa26377a domain: introduce provider-neutral media model (phase 1)
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.
2026-07-23 20:53:47 +02:00
dtourolle f89b241ad6 docs: document frontend/backend boundary rule and spec workflow
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.
2026-07-23 20:04:54 +02:00
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
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
2026-07-23 20:04:35 +02:00
dtourolle dd9d4191f1 chore(ci): add frontend boundary tripwire script
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.
2026-07-23 20:04:35 +02:00
dtourolle bacb9ca0bb docs(traces): tag episode focus view and library detail with TRACES
Add TRACES comments linking the episode focus view and library detail
page to their existing requirements.

TRACES: UR-035, UR-038, UR-048 | DR-043, DR-061, DR-062
2026-07-23 20:03:16 +02:00
dtourolle cf9472f04f feat(chrome): shared account menu and global app header
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
2026-07-23 20:03:12 +02:00
dtourolle f25deba824 feat(downloads): browsable downloaded library with on-disk usage
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
2026-07-23 20:02:55 +02:00
dtourolle 8f4f651bac fix(offline): gate library listing to downloaded-only when offline (#10)
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
2026-07-23 20:02:33 +02:00
dtourolle c175378f38 feat(search): context-scoped search with filter chips and group order
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
2026-07-23 20:02:15 +02:00
dtourolle e083b53ee8 feat(downloads): WiFi-only network-type-aware download gating
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
2026-07-23 20:02:07 +02:00
dtourolle 8f8433eebe ci: remove superseded traceability workflow
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.
2026-07-23 19:17:40 +02:00
dtourolle 6f057ad14a ci: fix Bad substitution in docs publish step
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.
2026-07-23 19:13:29 +02:00
dtourolle bebe13eb62 ci: drop redundant setup-bun step that stalls Gitea runner
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Failing after 5m23s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m41s
Build & Release / Build Linux (push) Successful in 17m20s
Build & Release / Build Android (push) Successful in 22m29s
Build & Release / Create Release (push) Successful in 5s
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.
2026-07-23 09:45:07 +02:00
dtourolle a8adbe25cc Merge pull request 'android-picture-in-picture' (#12) from android-picture-in-picture into master
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 3h14m1s
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Run Tests (push) Successful in 10m40s
Build & Release / Build Linux (push) Successful in 17m14s
Build & Release / Build Android (push) Successful in 22m26s
Build & Release / Create Release (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Reviewed-on: #12
2026-07-22 20:29:04 +00:00
dtourolle acf1bb200d fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled
2026-07-22 22:28:07 +02:00
dtourolle 3fbf6afdbc Background-audio handoff for video + repository/player refactor
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.
2026-07-22 21:52:07 +02:00
dtourolle 4e6ab017d4 docs: add mdBook docs-site, publish workflow, and release-notes tooling
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.
2026-07-22 21:51:56 +02:00
dtourolleandClaude Opus 4.8 027054a200 Bump version to 0.0.16
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>
2026-07-18 15:57:23 +02:00
dtourolleandClaude Opus 4.8 1fa5aa46f9 Android picture-in-picture, and fix three dead Android config files
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>
2026-07-18 12:59:39 +02:00
dtourolleandClaude Opus 4.8 7b8a8f66e5 CI: make versionCode step POSIX sh compatible
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m24s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m31s
Build & Release / Build Linux (push) Successful in 17m40s
Build & Release / Build Android (push) Successful in 22m33s
Build & Release / Create Release (push) Successful in 14s
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>
2026-07-18 12:23:04 +02:00
dtourolleandClaude Opus 4.8 2e479d05b3 Navigation up/back split, faster startup, and CI versionCode fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m57s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Build & Release / Build Linux (push) Successful in 17m52s
Build & Release / Build Android (push) Failing after 58s
Build & Release / Create Release (push) Has been skipped
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>
2026-07-17 21:12:36 +02:00
dtourolle 1992a8187d layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s
2026-07-16 22:53:03 +02:00
dtourolle 532ffa661a Fix tests
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m29s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m4s
Build & Release / Run Tests (push) Successful in 4m52s
Build & Release / Build Linux (push) Successful in 17m55s
Build & Release / Build Android (push) Successful in 22m13s
Build & Release / Create Release (push) Successful in 13s
2026-07-11 22:09:33 +02:00
dtourolle 2a1f1689b4 Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-07-11 19:55:55 +02:00
dtourolle a2cd9978f0 build uses android signing key
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m13s
Build & Release / Run Tests (push) Successful in 5m4s
Build & Release / Build Linux (push) Successful in 17m29s
Build & Release / Build Android (push) Successful in 21m44s
Build & Release / Create Release (push) Successful in 15s
2026-07-07 18:05:17 +02:00
dtourolle 36be192d44 offline mode fixes
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m39s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m11s
2026-07-07 16:22:12 +02:00
dtourolle acb7e5f221 fix offline mode and layout bugs
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m32s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Build & Release / Build Linux (push) Successful in 17m31s
Build & Release / Build Android (push) Successful in 22m5s
Build & Release / Create Release (push) Successful in 16s
2026-07-06 20:24:46 +02:00
dtourolle 68c8602230 Merge pull request 'fix-launcher-offline-mode' (#9) from fix-android-launcher-icon-conflict into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Reviewed-on: #9
2026-07-03 17:58:30 +00:00
dtourolle 2d141e5bf4 Fix for offline mode
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s
2026-07-03 19:37:34 +02:00
dtourolleandClaude Opus 4.8 c58cc0cf46 CI: replace broken per-commit Android APK build with a fast compile check
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>
2026-07-03 18:48:14 +02:00
dtourolleandClaude Opus 4.8 8938e3fdba Android launcher: drop monochrome (themed) icon, keep color only
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>
2026-07-03 18:47:50 +02:00
214 changed files with 20215 additions and 5355 deletions
+25 -39
View File
@@ -49,6 +49,13 @@ jobs:
run: |
bun install
# Tripwire for domain-taxonomy leaks into the presentation layer (a
# multi-type includeItemTypes query defining a category in the frontend).
# See scripts/check-frontend-boundary.sh and
# docs/specs/scoped-search-boundary.md.
- name: Check frontend/backend boundary
run: bash scripts/check-frontend-boundary.sh
- name: Run frontend tests
run: |
bunx svelte-kit sync
@@ -60,16 +67,24 @@ jobs:
cargo test
cd ..
build:
name: Build Android APK
# Fast per-commit Android compile check. This does NOT build a shippable APK:
# the full signed release APK is built only on tag pushes by build-release.yml
# (which runs sync-android-sources.sh + signing). Running the full bundle here
# too would duplicate a ~15min build and, without the sync step, produced an
# unsigned APK missing our custom sources/icons/proguard rules anyway.
# `cargo check` for the Android target (~1min) catches Android-specific Rust
# breakage without linking, bundling, or signing.
android-check:
name: Android Compile Check
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 27.0.11902837
ANDROID_SDK_ROOT: /opt/android-sdk
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
@@ -97,42 +112,13 @@ jobs:
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install
run: bun install
- name: Build frontend
run: bun run build
- name: Ensure Android NDK
run: |
if [ ! -d "$NDK_HOME" ]; then
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
fi
echo "Using NDK at $NDK_HOME"
ls "$NDK_HOME"
- name: Initialize Android project
- name: Cargo check (aarch64-linux-android)
run: |
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
export AR_aarch64_linux_android="$TC/llvm-ar"
cd src-tauri
echo "" | bunx tauri android init
cd ..
- name: Build Android APK
id: build
run: |
mkdir -p artifacts
bun run tauri android build --apk true --target aarch64
# Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-apk
path: ${{ steps.build.outputs.artifact }}
retention-days: 30
if-no-files-found: error
cargo check --target aarch64-linux-android --lib
+32 -3
View File
@@ -161,10 +161,10 @@ jobs:
- name: Set app version from tag
run: |
REF="${GITHUB_REF#refs/tags/v}"
VERSION="${REF#refs/heads/}"
# On non-tag runs keep whatever is in tauri.conf.json
# On a tag build, the tag is the single source of truth for the
# version name. On non-tag runs keep whatever is in tauri.conf.json.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
@@ -173,6 +173,35 @@ jobs:
- name: Initialize Android project
run: bun run tauri android init
- name: Pin a monotonic Android versionCode
run: |
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
# number is (a) tiny and (b) NOT monotonic across our history: earlier
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
# plain 15 would be a *downgrade* and Android would refuse the update.
#
# Derive an explicit code that is both monotonic in semver order and
# always above the 1000 floor already in the field:
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo "version=$VERSION -> versionCode=$CODE"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
cat "$PROPS"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
+124
View File
@@ -0,0 +1,124 @@
name: Publish Documentation
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
# API reference with cargo doc, and force-pushes the combined output to the
# orphan `gitea-pages` branch that the Gitea Pages server serves.
#
# The published matrix is regenerated during the build, so it is never stale.
on:
push:
branches:
- master
concurrency:
# Only one docs publish at a time; a newer push supersedes an in-flight run.
group: publish-docs
cancel-in-progress: true
jobs:
publish-docs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
- name: Install mdBook
run: |
set -e
MDBOOK_VERSION=v0.4.40
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
mdbook --version
- name: Regenerate traceability matrix (keep published copy current)
run: bun run traces:markdown
- name: Assemble mdBook sources
run: |
set -e
# mdBook's src is docs/. Drop in the SUMMARY and the generated
# intro + API redirect pages (build artifacts, not committed).
cp docs-site/SUMMARY.md docs/SUMMARY.md
cat > docs/README.md <<'EOF'
# JellyTau Documentation
Cross-platform Jellyfin client — business logic in a Rust backend,
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
_This site is published automatically from `master` by the `publish-docs` CI job._
EOF
cat > docs/api-redirect.md <<'EOF'
# Rust API Reference
The full backend API reference is generated by `cargo doc` (rustdoc).
👉 **[Open the Rust API Reference](api/index.html)**
EOF
- name: Build mdBook site
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
- name: Build Rust API docs (cargo doc)
working-directory: src-tauri
# --no-deps keeps it to our own crate (fast, focused); document private
# items so internal modules/commands appear.
run: |
cargo doc --no-deps --document-private-items
# The backend modules/commands live in the LIB crate (jellytau_lib);
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
> target/doc/index.html
- name: Assemble published output
run: |
set -e
mkdir -p "$GITHUB_WORKSPACE/site/api"
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
# Disable Jekyll processing on the pages branch.
touch "$GITHUB_WORKSPACE/site/.nojekyll"
ls -la "$GITHUB_WORKSPACE/site"
- name: Push to gitea-pages branch
env:
# PAT preferred; falls back to the auto-provided token (same pattern
# as build-release.yml).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
REPO="${GITHUB_REPOSITORY}"
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
cd "$GITHUB_WORKSPACE/site"
git init -q
git config user.name "gitea-actions"
git config user.email "actions@gitea.tourolle.paris"
git checkout -q -b gitea-pages
git add -A
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
git commit -q -m "docs: publish site from ${SHORT_SHA}"
echo "🚀 Force-pushing to gitea-pages"
git push -f "$REMOTE" gitea-pages
+2 -3
View File
@@ -25,9 +25,8 @@ jobs:
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
-175
View File
@@ -1,175 +0,0 @@
name: Requirement Traceability Check
on:
push:
branches:
- master
- main
- develop
pull_request:
branches:
- master
- main
- develop
jobs:
traceability:
name: Validate Requirement Traces
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Extract requirement traces
run: bun run traces:json > traces.json
- name: Validate trace format
run: |
if ! jq empty traces.json 2>/dev/null; then
echo "❌ Invalid traces.json format"
exit 1
fi
echo "✅ Traces JSON is valid"
- name: Check requirement coverage
run: |
set -e
# Extract coverage stats
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
UR_COUNT=$(jq '.byType.UR | length' traces.json)
IR_COUNT=$(jq '.byType.IR | length' traces.json)
DR_COUNT=$(jq '.byType.DR | length' traces.json)
JA_COUNT=$(jq '.byType.JA | length' traces.json)
echo "## 📊 Requirement Traceability Report"
echo ""
echo "**Total TRACES Found:** $TOTAL_TRACES"
echo ""
echo "### Requirements Covered:"
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
echo ""
# Set minimum coverage threshold (50%)
TOTAL_REQS=114
MIN_COVERAGE=$((TOTAL_REQS / 2))
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
echo ""
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
exit 1
else
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
fi
- name: Check for new untraced code
run: |
set -e
# Find files modified in this PR/push
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
else
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
fi
if [ -z "$CHANGED_FILES" ]; then
echo "✅ No source files changed"
exit 0
fi
echo "### Files Changed:"
echo "$CHANGED_FILES" | sed 's/^/- /'
echo ""
# Check if changed files have TRACES
UNTRACED_FILES=""
while IFS= read -r file; do
if [ -f "$file" ]; then
# Skip test files and generated code
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
continue
fi
# Check if file has TRACES comments
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
UNTRACED_FILES+="$file"$'\n'
fi
fi
done <<< "$CHANGED_FILES"
if [ -n "$UNTRACED_FILES" ]; then
echo "⚠️ New files without TRACES:"
echo "$UNTRACED_FILES" | sed 's/^/ - /'
echo ""
echo "💡 Add TRACES comments to link code to requirements:"
echo " // TRACES: UR-001, UR-002 | DR-003"
else
echo "✅ All changed files have TRACES comments"
fi
- name: Generate traceability report
if: always()
run: bun run traces:markdown
- name: Upload traceability report
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-report
path: docs/traceability.md
retention-days: 30
- name: Comment PR with coverage report
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
const urCount = traces.byType.UR.length;
const irCount = traces.byType.IR.length;
const drCount = traces.byType.DR.length;
const jaCount = traces.byType.JA.length;
const total = urCount + irCount + drCount + jaCount;
const coverage = Math.round((total / 114) * 100);
const comment = `## 📊 Requirement Traceability Report
**Coverage:** ${coverage}% (${total}/114 requirements traced)
### By Type:
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
**Total Traces:** ${traces.totalTraces}
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
+6
View File
@@ -58,3 +58,9 @@ android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
/docs/SUMMARY.md
/docs/README.md
/docs/api-redirect.md
/docs-site/book/
+266
View File
@@ -0,0 +1,266 @@
# JellyTau
A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
`<video>` for transcoded playback) and **Android** (ExoPlayer).
Package manager is **bun**.
## Build / Run / Test
All routine tasks go through `package.json` scripts and helper scripts in
`scripts/`:
```bash
bun install # install deps
bun run dev # vite dev server (frontend)
bun run tauri dev # run the desktop app
bun run check # svelte-check (types)
bun run test # vitest (frontend unit/integration)
bun run test:rust # cargo test (scripts/test-rust.sh)
bun run test:all # full suite (scripts/test-all.sh)
bun run test:e2e # webdriverio e2e
# Android — canonical entry points (see scripts/):
bun run android:build # debug APK
bun run android:build:release # release APK
bun run android:deploy # install to connected device
bun run android:dev # build + deploy
bun run android:logs # logcat
```
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
## Before Committing
- Frontend: `bun run check` and `bun run test` must pass.
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
item-type category sets) leaked into the frontend. See below.
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
comment (see below).
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
Never edit the generated `gen/` sources directly.
## Traceability (TRACES)
This project practices requirement-driven development: code that implements a
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
an extraction tool builds the traceability matrix. **When you add or change code
that implements a requirement, add/update its TRACES comment.** Internal helpers
and requirement-less code stay untraced.
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
```rust
/// TRACES: UR-005 | DR-001
pub enum PlayerState { }
```
```typescript
// TRACES: UR-005, UR-026 | DR-029
export function autoplayNextEpisode() { }
```
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
in [docs/requirements.md](docs/requirements.md); the generated matrix is
[docs/traceability.md](docs/traceability.md).
Tooling:
```bash
bun run traces # extract traces (default format)
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown # regenerate docs/traceability.md
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
```
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
GitHub. `traceability-check.yml` fails the build if coverage drops below
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
### Traces drive release notes
Prefer traceability over raw commit subjects when writing release notes for
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
release touched.
```bash
bun run release:notes # <latest tag>..HEAD
bun run release:notes v0.0.15..HEAD # explicit range
```
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
changed files → their `TRACES:` IDs → descriptions in
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
and **DR/IR** into *Improvements* (deduped, so many commits touching one
requirement collapse to one line). It also lists changed files that carry no
TRACES so nothing is silently dropped — those still need a manual line. Treat the
output as a reviewed draft, not a final changelog.
## Architecture
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
sessions, downloads, offline cache, playback control. Commands grouped by
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
`download/`, `offline.rs`, `sessions.rs`, …).
- **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`.
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
ExoPlayer with a foreground media service + `MediaSessionCompat`.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
**Read the architecture docs before making structural changes** — they are the
canonical, maintained source; this file only summarizes. See
[docs/architecture/README.md](docs/architecture/README.md) and:
| Doc | Contents |
|-----|----------|
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
and [docs/build-release.md](docs/build-release.md).
### Core principles (from the architecture docs)
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
Linux, session poller in remote mode) is the **authoritative source** of state
— position, pause, seeking, rate, track changes. The Svelte UI, OS
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
commands, so the controller stays the single source of truth in both native
and HTML5 modes.
- **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while
offline*, as a recovery detector.
- **Poison-tolerant locking.** Access shared `std::sync` state via the
`MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned
lock instead of cascading a panic across the player.
- **Graceful backend init.** If a native player backend fails to initialize, the
app falls back to a no-op backend and emits `backend-init-failed` rather than
crashing.
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
category like "Music". Send an opaque scope/enum across the boundary and let the
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
for the incident this rule came from.
## Writing specs
New feature specs go in [docs/specs/](docs/specs/). **Start from
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
Before accepting a spec, run it past
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
a spec around "no Rust changes required" — correct layer placement is the goal,
not minimal backend churn.
## Conventions
### Rust Backend
- Use `#[tauri::command]` for all IPC handlers.
- Prefer `async` commands for I/O-bound work.
- Return `Result<T, String>` from commands (the established convention here).
- Use `tauri::State<>` for shared state.
- Group related commands in domain modules under `commands/`.
- Use official Tauri plugins before writing custom native code.
### Frontend
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
- Define TS types matching the Rust structs; prefer the generated bindings.
- Handle IPC errors with try/catch.
- Use `@tauri-apps/api/path` for paths (never hardcode).
- Use `@tauri-apps/api/event` for backend→frontend events.
### 🔴 IPC parameter naming (Tauri v2)
The command **name** must match the Rust function name exactly
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
on the frontend:
```rust
#[tauri::command]
pub async fn cmd(repository_handle: String) { }
```
```typescript
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
```
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
### Events
- Backend events use **kebab-case** names (`download-event`, `search-event`).
- Emit from Rust via `emit(...)`; consume on the frontend via
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
### Security
- Declare minimum permissions in `src-tauri/capabilities/`.
- Keep the CSP restrictive in `tauri.conf.json`.
- Validate all inputs in Rust command handlers.
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
asking the user first.
## Gotchas (hard-won)
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
(it flips to HTML5 mode and breaks Android seek).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
Don't loop `startDownload` from the frontend.
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
file changes may be another session — check `git diff` before "repairing".
## Testing
```bash
# Rust
cd src-tauri && cargo test
cd src-tauri && cargo test test_name # single test
# Frontend
bun run test
bun run test:coverage
# Tauri IPC param-naming integration tests (guard the camelCase rule):
bun run test -- tauriIntegration.test.ts
```
+60 -19
View File
@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.16",
"@wdio/cli": "^9.5.0",
"@wdio/local-runner": "^9.5.0",
@@ -30,7 +31,7 @@
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.0.16",
"vitest": ">=1.0.0 <5.0.0",
"webdriverio": "^9.5.0",
},
},
@@ -46,10 +47,18 @@
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
@@ -348,6 +357,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
@@ -362,7 +373,7 @@
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
@@ -422,6 +433,8 @@
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
@@ -498,6 +511,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
@@ -686,6 +701,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
@@ -738,6 +755,12 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -756,7 +779,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -828,6 +851,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1020,7 +1047,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
@@ -1042,7 +1069,7 @@
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
@@ -1068,7 +1095,7 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
@@ -1166,6 +1193,10 @@
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1190,10 +1221,24 @@
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1260,6 +1305,8 @@
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
@@ -1298,6 +1345,12 @@
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
@@ -1328,7 +1381,7 @@
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
@@ -1338,34 +1391,24 @@
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1432,8 +1475,6 @@
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+39
View File
@@ -0,0 +1,39 @@
# Summary
[Introduction](README.md)
# Requirements & Traceability
- [Requirements Specification](requirements.md)
- [Traceability Matrix](traceability.md)
- [Traceability CI](traceability-ci.md)
- [Traces Quick Reference](traces-quick-ref.md)
# Architecture
- [Overview](architecture/README.md)
- [Rust Backend](architecture/01-rust-backend.md)
- [Svelte Frontend](architecture/02-svelte-frontend.md)
- [Data Flow](architecture/03-data-flow.md)
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
- [Platform Backends](architecture/05-platform-backends.md)
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
- [Connectivity](architecture/07-connectivity.md)
- [Database Design](architecture/08-database-design.md)
- [Security](architecture/09-security.md)
# UX & Specs
- [UX Flows](ux-flows.md)
- [Video Background Audio](specs/video-background-audio.md)
# Build & Release
- [Build & Release](build-release.md)
- [Release Checklist](release-checklist.md)
- [Docker](build/docker.md)
- [Builder Image](build/build-builder-image.md)
---
[Rust API Reference (rustdoc)](api-redirect.md)
+25
View File
@@ -0,0 +1,25 @@
# mdBook config for the published JellyTau documentation site.
# The book's `src` is the repo `docs/` directory (see [build] below); this file
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
[book]
title = "JellyTau Documentation"
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
authors = ["Duncan Tourolle"]
language = "en"
# Sources live in the repo docs/ dir (one level up from this book root).
src = "../docs"
[output.html]
default-theme = "navy"
preferred-dark-theme = "navy"
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
[output.html.fold]
enable = true
level = 1
[output.html.search]
enable = true
+20 -8
View File
@@ -51,14 +51,19 @@ graph TD
**View Enforcement:**
Ordinal content (where position carries meaning) is always a list. Everything
else honours the user's persisted grid/list preference — see
[ux-flows.md §5A.2](../ux-flows.md).
| Content Type | View Mode | Toggle Visible | Component Used |
|--------------|-----------|----------------|----------------|
| Tracks | List (forced) | No | `TrackList` |
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
| Album Detail Tracks | List (forced) | No | `TrackList` |
| Tracks | List (forced — ordinal) | No | `TrackList` |
| Artists | User preference | Yes | `LibraryGrid` |
| Albums | User preference | Yes | `LibraryGrid` |
| Playlists | User preference | Yes | `LibraryGrid` |
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
**TrackList Component:**
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
/>
```
**LibraryGrid forceGrid Prop:**
**LibraryGrid view mode:**
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
prop controls whether the toggle buttons appear in the page header; the grid
itself always follows the stored preference.
A `forceGrid` prop previously existed to pin pages to grid regardless of
preference. No caller ever passed it, so it was removed — pages that were
documented as "forced grid" have in practice always honoured the toggle.
## Playback Reporting Service
+87 -1
View File
@@ -50,6 +50,24 @@ For a narrative overview of the system design, see
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
---
@@ -85,6 +103,11 @@ External system integrations and platform-specific implementations.
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
### 2.2 Jellyfin API Requirements
@@ -123,6 +146,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
### 2.3 Development Requirements
@@ -180,6 +204,39 @@ Internal architecture, components, and application logic.
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog``INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
---
@@ -198,7 +255,7 @@ Internal architecture, components, and application logic.
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
@@ -228,6 +285,24 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052 |
| UR-041 | IR-026 | DR-053 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
| UR-047 | IR-013 | DR-060 |
| UR-048 | - | DR-061, DR-062 |
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
| UR-050 | - | DR-066, DR-067 |
| UR-051 | - | DR-068, DR-069, DR-070 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
---
@@ -295,6 +370,14 @@ Internal architecture, components, and application logic.
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
### Integration Tests
@@ -312,6 +395,9 @@ Internal architecture, components, and application logic.
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
---
+68
View File
@@ -0,0 +1,68 @@
# Spec review checklist
Run a spec past this before accepting it. It exists because JellyTau's
backend/frontend boundary is a **stated rule with, historically, no gate** — the
rule lived in the architecture docs, but nothing forced a spec author to check a
new design against it, and a "minimal-change" spec quietly leaked domain
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
This checklist is the human gate. The CI check
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
signature — it does **not** replace this.
Copy the boxes into the review comment (or the PR) and tick them.
## Boundary (the one that bites)
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
*logic*, not files. A spec without this section is not ready to review.
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
query-shaping rules, business rules, reachability/sync policy. If the
frontend names a *set* of item types to define a category, that is a leak —
it belongs behind an opaque enum the backend expands.
- [ ] **"The backend already accepts this parameter" was not used as the reason**
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
the decision of its value.
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
change."** "Frontend only, no Rust changes" is a description, never a goal.
The goal is *correct layer placement*; sometimes that is more Rust work.
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?*
frontend. Borderline defaults to Rust.
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
is **not** over-corrected into the backend. The rule targets category
*taxonomy*, not every mention of a type. Don't invent a backend enum per
list page.
## IPC contract
- [ ] Anything crossing the boundary has its wire shape specified.
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
sides; events are kebab-case. (CLAUDE.md §IPC,
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
- [ ] Any result that arrives *twice* (command return **and** a later event —
e.g. the search cache/server merge) has **both** payloads in the new shape.
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
## Requirements & traceability
- [ ] Linked to existing URs, or new URs/DRs are allocated in
[requirements.md](../requirements.md).
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
## Conflicts & hygiene
- [ ] If this spec revises/supersedes another, the older spec gets a banner
pointing here — no two specs silently contradicting.
- [ ] Acceptance criteria include the standard gates: `bun run check`,
`bun run test`, `bun run check:boundary`, and (if Rust changed)
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
- [ ] Notes flag that a parallel Claude session may be active in the repo.
---
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
assignment first. Every other section can be negotiated; that one is the whole
reason this file exists.
+105
View File
@@ -0,0 +1,105 @@
# Spec: <feature name>
<!--
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
comments as you go. The section that matters most for this project is
"Layer assignment" — read its comment before writing it.
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
-->
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
## Summary
<!-- 24 sentences. What changes for the user, in plain terms. -->
## Motivation
<!-- Why now. The problem being solved. -->
## Layer assignment
<!--
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
do NOT reframe it as "how little backend work can we get away with."
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
navigation, view/order preferences, input handling.
For each distinct piece of *logic* this feature introduces, put it in the table
and name the layer it belongs to and WHY. "It's less work in the frontend" and
"the backend already accepts this parameter" are NOT reasons to place logic in
the frontend — the backend accepting a parameter does not make deciding that
parameter's value a presentation concern.
Litmus test for "does this belong in Rust?": Would this logic have to change if
Jellyfin changed its API, added an item type, or altered a business rule? If
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
yes (and only yes), it is presentation → frontend.
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
means as a set of Jellyfin types) in the frontend because the backend already
accepted an includeItemTypes filter. That was a boundary leak; see
scoped-search-boundary.md. This section exists to catch that class of mistake
at spec time, not in review three features later.
-->
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
<!--
If a row is genuinely borderline, say so and give the tie-breaker you used.
Borderline defaults to Rust for anything touching domain data or vocabulary.
-->
## Design
<!--
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
- Command NAME must match the Rust fn name exactly.
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
- Nested struct fields need #[serde(rename_all = "camelCase")].
- Events are kebab-case.
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
Regenerate bindings.ts from Rust types; never hand-edit it.
-->
## Out of scope
<!-- What this spec deliberately does NOT do. -->
## Acceptance criteria
<!-- Checkable statements. Include the standard gates: -->
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated if Rust types changed.
## Testing
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
## TRACES
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
## Notes for the implementer
<!--
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
- Anything else non-obvious.
-->
+164
View File
@@ -0,0 +1,164 @@
# Spec: Account menu and global chrome availability
**Status:** Implemented
**Scope:** Frontend only. No Rust changes required.
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
[requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §1.21.4](../ux-flows.md).
## Summary
Account actions — Settings, Downloads, Display preferences, Sign out — are
currently reachable **only from `/library/*`**. Move them into a single shared
account menu anchored to the user's name, and make that menu available on every
authenticated non-immersive screen.
## Motivation
A user sitting on the home screen cannot open Settings or sign out. The bottom
nav offers Home / Search / Library only, and the header that hosts those actions
belongs to the library layout. The user has to guess that account actions live
*inside* Library — an unrelated section — and navigate there first.
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
same two actions are found two different ways.
## Background: verified current state
1. **The header is not global.** It is defined in
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
all.
2. **`routeOwnsLayout`** in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
`/library`, `/player/`, `/login` — those routes own their own full-height
flex column. Everything else renders into the root scroller with the root's
`BottomUi` below it.
3. **Bottom nav is Home / Search / Library only**
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
or account entry.
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
Settings or Sign out.
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
trigger.
6. **The mobile overflow menu already has the right contents** (Downloads,
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
7. **`viewMode` is already a persisted store** in
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
`localStorage`). The Display setting is a second view onto it — **no new
state, no migration.**
## Design
### `AccountMenu` component (DR-075)
One component used by both breakpoints. Contents in fixed order:
```
Signed in as <name> ← identity block, not interactive
<server host>
────────────────────────
Downloads
Settings
Display ← grid/list preference
────────────────────────
Sign out ← destructive, last, after a divider
```
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
horizontal space is tight, the avatar (or initial) alone is acceptable; the
name shows inside the open menu regardless.
- **Same items, same order, both platforms.**
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
and focus return to the trigger on close.
- Menu items are real links/buttons — keyboard reachable, correct roles,
`aria-expanded` on the trigger.
"Display" may either navigate to the Settings Display section or expose the
grid/list choice inline. Prefer navigating — it keeps one source of truth for
preferences and avoids a nested control inside a dropdown.
### Global chrome (DR-076)
Make the header — and therefore the account menu — available on `/`, `/search`,
and `/downloads`.
The cleanest route is to lift the header out of the library layout into a shared
component rendered by the root layout, with the library layout consuming the
same component rather than defining its own. **Do not duplicate the markup into
each route.**
Constraints that must survive the change:
- `/player/*` and `/login` stay chrome-free.
- `/settings` already owns its layout; it needs no account menu (the user is
already there), but must not double up on chrome.
- The root layout's flex/scroller structure is deliberate — the comments in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
own column. Preserve the scroll containment; a regression here reintroduces
the "last row hidden behind the nav" bug called out in those comments.
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
`showBottomNav`) must be unchanged.
### Display section in Settings (DR-077)
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
with the grid/list control bound to the existing `viewMode` store via
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
controls drive the same store, so they stay in sync for free.
## Out of scope
- Redesigning the Settings page or reorganising its existing sections.
- Multi-server / account switching (UR-047) — the identity block displays the
active server but offers no switcher.
- Changing the bottom nav's three destinations.
## Acceptance criteria
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
without first navigating into Library.
- [ ] Desktop and mobile show the same account menu items in the same order.
- [ ] The username/avatar opens the menu; it is a real button with
`aria-expanded`.
- [ ] Sign out is last, after a divider, and still logs out + resets library
state + redirects as it does today.
- [ ] `/player/*` and `/login` remain chrome-free.
- [ ] Settings has a Display section that changes grid/list, and the change is
immediately reflected by the library page-header toggle (same store).
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
visibility on any route.
- [ ] `bun run check` and `bun run test` pass.
## Testing
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
`/downloads` (now true) and `/player/*`, `/login` (still false).
- `AccountMenu`: renders the documented items in order; trigger toggles
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
logout handler.
- Display setting: writes through to the `viewMode` store and persists.
New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu``UR-054 | DR-075`,
shell/header changes → `UR-054 | DR-076`, Settings Display section →
`UR-054, UR-029 | DR-077`.
## Notes for the implementer
- Read [ux-flows.md §1.21.4](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The layout shell is subtle and the existing comments record real bugs that
were fixed there. Read them before restructuring.
- Another session may be active in this repo, including in
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
unexpected changes, and expect to coordinate on that file.
+166
View File
@@ -0,0 +1,166 @@
# Spec: Downloads as a browsable offline library
**Status:** Draft — ready to implement
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §7.27.7](../ux-flows.md).
## Summary
Replace the flat Active/Completed download list with two views under
`/downloads`:
1. **Downloaded** (default) — the library, filtered to what's on the device,
using the *same* browse screens as online (grids, cards, detail pages).
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
showing only in-flight transfers.
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
pages, a device total, and the remove confirmation.
## Motivation
A user who downloaded three seasons and two albums sees ~70 individual transfer
rows today, with no grouping and no reuse of the library UI. "What do I have
offline" and "what is downloading" are different questions crammed into one flat
list. Browsing offline should feel exactly like browsing online.
## Background: verified current state
1. **The offline repository is already a browsable tree.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
least one downloaded child. `get_libraries`, `get_item`, and `search` all
filter to downloaded content via CTEs. This is the data source for
Downloaded; **do not build a new query layer.**
2. **The client cannot reach it independently.**
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems`
`repositoryGetItems` always goes through the **hybrid** repository
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
server. There is no "offline only" browse path exposed. This is the one real
backend gap (DR-082).
3. **Downloads page is a flat two-tab list.**
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
Completed tabs, one `DownloadItem` row per transfer, no browsing.
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
given. Downloaded browse is those components with an offline-scoped source.
5. **A related fallthrough bug is already tracked** (DR-080, another session):
`HybridRepository::get_items` treats an empty offline result as a cache miss
and falls through to the server. The offline-only browse path (DR-082) must
**not** share that behaviour — an empty result there is authoritative "nothing
downloaded here."
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
Do not surface them as manual controls; do not loop `startDownload` from the
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
## Design
### View split (DR-081)
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
default. Transfers shows a count/badge only while transfers are active.
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
does not start downloads.
### Offline-scoped browse source (DR-082, DR-083)
Add an explicit offline-only browse path so Downloaded never merges server
results and never depends on reachability. Two viable shapes — pick per the
codebase, do not do both:
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
`_libraries`) that calls the offline repository directly, with a matching
client method; or
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
that bypasses the hybrid merge and the empty→fallthrough behaviour.
Either way: an empty result is authoritative (do **not** reuse the DR-080
fallthrough), and the path is available while the server is reachable (a user
online still wants to browse their downloads).
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
source. Omit libraries and containers with no downloaded content. Badge
partially- vs fully-downloaded containers. Play uses the local file; remove is
available at item / album / season / series level and removes a container from
the browse when its last downloaded child goes.
### Transfers view (DR-084)
The existing list, filtered to in-flight rows only: downloading (with progress),
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
this view — they appear in Downloaded. Empty state points at the library.
### Disk usage (DR-085, UR-056)
- **Source the bytes from the download manager** — it writes the files and can
stat them. Aggregate to album/season/series subtotals and a device total.
This is display + aggregation, **not** new tracking.
- **Format once, consistently.** One shared formatter, human units, 23
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
it everywhere.
- **Surface it in familiar places:** a secondary size label on the card and
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
that reconciles with the listed sum; a reclaim figure in the remove
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
- Sort/filter by size is a nice-to-have, not required for v1.
## Out of scope
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
that governs the *online offline-fallback* library; this is the dedicated
Downloads surface. They should be consistent but are separate work.
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
on that behaviour here.
## Acceptance criteria
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
- [ ] Downloaded lists only libraries/containers with downloaded content, using
the same grids/cards/detail pages as online browsing.
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
through to the server.
- [ ] Play from Downloaded plays the local file.
- [ ] Remove works at item/album/season/series level and updates the browse.
- [ ] Transfers shows only in-flight rows with working controls; finished
transfers move to Downloaded.
- [ ] Each downloaded item/container shows its on-disk size; a device total is
shown and reconciles with the sum; remove states the reclaim amount.
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
`cargo clippy` pass.
## Testing
- Repository client: the offline-only browse path returns downloaded content and
its containers, and an empty result does **not** trigger server fallthrough.
- Downloaded view: libraries/containers with no downloads are omitted;
partial/full container badging.
- Transfers: only in-flight statuses render; a completed transfer disappears.
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
total reconciles with listed items.
- If a Rust command is added, add the tauri IPC param-naming coverage per
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
## Notes for the implementer
- Read [ux-flows.md §7.27.7](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The offline repository already does the hard part. The main work is a clean
offline-only client path and reusing the library components — resist
rebuilding browse UI.
- Another session is active in downloads/offline/connectivity code (DR-074,
DR-078080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
and the repository layer; check `git diff` before repairing unexpected changes.
+309
View File
@@ -0,0 +1,309 @@
# Spec: Remove Jellyfin-specific models from the frontend
> **Implementation status (branch `frontend-domain-model`, worktree
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
> classification* and *time units* no longer speak Jellyfin:
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
> `from_jellyfin` mapping. The model gained real distinctions the flat
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
> - Every catalog `item.type === "..."``item.kind` (0 remaining in `src/`).
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
> `formatDuration` takes ms; progress bars are unit-consistent.
> - User-facing type badge → `kindLabel()`.
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
>
> **Deferred (tracked, not done):**
> - `primaryImageTag``imageId` rename (naming-only; ~40 sites across catalog +
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
> round-trip). Catalog `MediaItem` already has `imageId`.
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
> signatures* in ticks; needs those commands to accept ms (phase 4).
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
> consumers migrate; drop the dual-carried fields once nothing reads them.
**Status:** Partially implemented (catalog surface); see banner.
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
architecture/boundary migration.
**Supersedes / revises:** none. Extends the boundary work started in
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
*whole media model*.
## Summary
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
specta into **36+ frontend files**, with **127 `item.type === "…"` string
comparisons across 23 files** and two frontend utility modules
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
field conversion in the presentation layer.
This spec defines a **provider-neutral domain model**, owned by Rust, that the
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
names — remains in `src/`.
## Motivation
Two concrete problems, one strategic:
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
presentation-only and Rust owns the domain. Today the *domain model itself* is
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
is domain knowledge in the wrong layer, 36 files deep.
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
rename fails silently at runtime with no compiler help, across 127 sites. Tick
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
should have already resolved.
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
shut.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"``Track`, `PremiereDate``releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
**Borderline calls, resolved:**
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
subset of the new domain model, not a special case.
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
playback context, sessions) are **already domain-neutral** — they are *our*
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
`item_type`. Only the Jellyfin item-type strings move.
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
self-contained — deferred to a late phase, not phase 1.
## Design
### Single canonical model, one location, isolated mappings
The domain model is defined **once**, in a dedicated top-level Rust module
`src-tauri/src/domain/`, and is the single source of truth shared across the
whole app:
```
src-tauri/src/domain/
media.rs canonical MediaItem, MediaKind, and the other media types
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
mod.rs re-exports
| tauri-specta (export_typescript_bindings test)
v
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
```
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
projection of it. There is no second hand-written copy in either language, so it
cannot drift — "shared between frontend and backend" is realized by generation,
not duplication.
- **Mappings live beside the model, never in consumers.** All provider translation
(`JellyfinItem``domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
touches the domain type. Adding a second provider later means a new
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
is used by `player/`, `download/`, and `playback_mode/` too — it is not
repository-specific.
- The existing `JellyfinItem` DTO + `to_media_item()` in
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
translation instead of copying `item_type` through.
### The domain model (Rust)
```rust
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum MediaKind {
Track, Album, Artist, Playlist, // music
Movie, Series, Season, Episode, // video
Person, // cast/crew
Channel, Folder, // containers/live
}
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
pub id: String,
pub name: String,
pub kind: MediaKind, // was: type: String
pub is_folder: bool,
pub server_id: String,
// Times in milliseconds — NEVER ticks.
pub duration_ms: Option<i64>, // was: run_time_ticks
// Image as a resolved identifier the frontend turns into a URL via the
// existing image command — no raw Jellyfin tag semantics leak.
pub image_id: Option<String>, // was: primary_image_tag
pub backdrop_image_ids: Option<Vec<String>>,
pub overview: Option<String>,
pub genres: Option<Vec<String>>,
pub production_year: Option<i32>,
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
pub community_rating: Option<f64>,
pub official_rating: Option<String>,
// Relationships — already neutral, kept.
pub album_id: Option<String>, pub album_name: Option<String>,
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
pub artist_items: Option<Vec<ArtistItem>>,
pub series_id: Option<String>, pub series_name: Option<String>,
pub season_id: Option<String>, pub season_name: Option<String>,
// Ordinal position — rename off Jellyfin's index vocabulary.
pub track_number: Option<i32>, // was: index_number
pub disc_number: Option<i32>, // was: parent_index_number
pub user_data: Option<UserData>,
pub media_streams: Option<Vec<MediaStream>>,
pub media_sources: Option<Vec<MediaSource>>,
pub people: Option<Vec<Person>>,
}
```
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
private to that module. Its `to_media_item()` — today a near-passthrough that
copies `item_type` straight across — is enriched into the single, tested place
that:
- classifies `item_type: String``MediaKind` (including the edge cases found in
the audit: `"ChannelFolderItem"``Channel`/`Folder` by `is_folder`,
`"TvChannel"``Channel`, `"Composer"/"Director"/"Writer"``Person`,
`"Video"``Movie` or a video leaf). Unknown strings map to `Folder` or a new
`Other` variant — **decide at implementation; must not panic.**
- converts `run_time_ticks``duration_ms` (`ticks / 10_000`).
- maps `PremiereDate``release_date`, image tags → image ids.
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
### 🔴 The `search-event` / dual-payload rule applies again
Every path that returns `MediaItem` — command returns **and** the `search-event`
and any other event payloads — emits the new domain shape. Both sides of a
twice-delivered result must match (same rule as
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
event definitions before declaring a phase done.
### Frontend after
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
- `item.type === "Audio"``item.kind === "track"` (127 sites, mechanical).
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
they exist, which are not Jellyfin-specific).
- `primaryImageTag``imageId` through the existing image-URL command.
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
- Assert with the boundary tripwire + a new grep (see acceptance).
## Phased migration
This is too large and too collision-prone for one change. Phases are independently
shippable, each keeps all tests green, and each is a reviewable PR:
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
wire shape is a superset of today's, so the frontend still compiles and
behaves identically. Lands the authority + full mapping unit coverage first,
with zero blast radius on the 52 construction sites (they set the old fields;
new ones default).
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
mechanically (`type``kind`, values `"Audio"``"track"`, `runTimeTicks`
`durationMs`, `primaryImageTag``imageId`). This is the big mechanical PR;
`bun run check` is the driver.
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
outside tests.
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
## Out of scope
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
- Changing any user-visible behaviour, layout, or copy.
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
they carry the fields being renamed — align them in phase 2 only if the compiler
demands it.
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
## Acceptance criteria
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
item type (Rust test with a garbage type string).
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
same navigation, offline included.
- [ ] Both command returns and event payloads carry the new shape (no flicker).
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
## Testing
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
critical surface —
- every known `item_type` → correct `MediaKind` (table test over all 20 values
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
- unknown type string → safe fallback, no panic;
- `run_time_ticks``duration_ms` (10_000 divisor), boundary/None cases;
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
deleted with their modules. Add a compose/render test proving `kind`-based
branching matches the old `type`-based branching for a representative mix.
## TRACES
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
phases share the DRs of the capability each touches (don't invent per-file DRs).
## Notes for the implementer
- **This is the highest-collision change in the repo's history** — it touches 36+
frontend files and the core Rust types. A parallel Claude session in any media
file will conflict. Strongly prefer a dedicated worktree per phase, and
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
the disruptive flip. Resist the urge to skip it.
- IPC camelCase rules apply to the new enums/structs
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
`bindings.ts`, never hand-edit.
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
Layer assignment table above is the load-bearing section.
@@ -0,0 +1,235 @@
# Spec: Offline "downloaded only" filtering (issue #10)
**Status:** Implemented
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
commands, no schema changes, no UI additions.
**Requirements:** UR-052 → DR-078, DR-079, DR-080
(see [requirements.md](../requirements.md)).
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
media does not work."*
## Summary
Offline, a library page is supposed to show **only media on the device**, with a
"Show all server media" toggle that additionally reveals the cached server
catalog greyed out (queueable for download on reconnect). In practice the toggle
does not gate the listing — every server item still appears. This spec fixes
that with two independent changes; either one alone leaves the bug visible.
## Background: what already exists
Verified in code. **The feature is built and mostly correct — this is a
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
command, or the SQL gate.
1. **The SQL gate works and is unit-tested.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
true; with it false, only downloaded/local rows return. Guarded by
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
query.**
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
`set_show_server_catalog` command
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
`include = connected || showCatalog` and pushes it on every change.
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
them.
4. **`MediaCard` already greys and queues.**
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
`isServerOnly` renders the greyed, inert card with a queue button; the queued
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
it.
## The two defects
### Defect A — offline is never actually entered (DR-079)
`pushCatalogVisibility` keys off `isConnected`, but
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
```ts
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
```
`navigator.onLine` is documented in that same file as **advisory only** — the
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
real traffic*, DR-055). When the server is unreachable but the device link is
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
stays true, so `include` stays true, so the backend keeps returning the full
catalog. The user is "offline" in every meaningful sense but the toggle never
gets a chance to gate anything.
This is the primary cause: it explains why the filter looks dead rather than
merely inverted — the gate never closes.
### Defect B — an intentionally empty result falls through to the server (DR-080)
With the gate off and nothing downloaded in a library, offline `get_items`
correctly returns few or zero rows. But
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
hit only `if data.has_content()`. An empty offline result is indistinguishable
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
~10 other reads) falls through to the server and returns the full server list —
re-defeating the filter even after Defect A is fixed.
## Design
### Fix A: `isConnected` follows backend reachability alone (DR-079)
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
store:
```ts
export const isConnected = derived(
connectivity,
($c) => $c.isServerReachable
);
```
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
immediate recheck (`online`/`offline` listeners already call
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
Leave `isOnline` on the state object and the listeners intact.
Consider whether the optimistic `isServerReachable: true` startup default
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
first real check resolves. Keep it — flipping the app to "offline" on launch is a
worse regression than a brief full-catalog flash before the first probe. Note the
choice in a comment.
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
anything importing it). Enumerate consumers first:
```
grep -rn "isConnected" src/ | grep -v node_modules
```
For each, confirm "server unreachable" (not "device link down") is the correct
trigger. It almost always is — that is the whole point of the reachability model
— but verify rather than assume, and call out anything that genuinely wanted the
device link in the PR description.
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
The backend must distinguish "cache is cold, go ask the server" from "user asked
for downloads only and there are none here." The gate flag already encodes intent
— reuse it.
Add a getter beside the existing setter in
[offline.rs](../../src-tauri/src/repository/offline.rs):
```rust
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
```
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
`!include_catalog_browse()`, treat the offline result as authoritative and return
it **as-is even when empty** — do not spawn/await the server fallback for this
call. When the flag is on (online fast-path, or offline with the toggle on),
behaviour is unchanged: empty cache still falls through to the server.
Keep it surgical:
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
thread it into `parallel_race` or the other readers, which have no catalog
gate and legitimately want the server on an empty cache.
- Preserve the online path exactly: with the flag on (its default, and always so
while reachable) the method behaves as it does today, including the background
cache refresh on a hit.
- The flag is process-global `Relaxed`; it is set from the frontend before the
query. That ordering already holds for the SQL gate — no new synchronization.
### Why both
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
downloads present the list still gets padded by the server fallback whenever a
library's cache is thin. B alone: the gate never closes because `isConnected`
never goes false on a live link. Ship them together.
## Out of scope
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
- `MediaCard` greying / queue-on-reconnect — correct.
- Home-screen and resume queries — already downloads-only.
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
- Any new IPC command, DB column, or settings entry.
- Making the "Show all server media" toggle reachable from Settings (that is a
UX-placement question, tracked separately under UR-051's toggle note).
## Acceptance criteria
- [~] With the server unreachable on a live device link, a library page lists
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
coverage via UT-069 + gate tests).
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
hides it again — without leaving/re-entering the page (SQL gate + toggle
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
- [x] A library with downloads and a thin cache does not get padded with
non-downloaded server items when offline with the toggle off (Defect B —
UT-070: gate off + empty offline result returned as-is, server not queried).
- [x] `isConnected` is false whenever the server is unreachable, regardless of
`navigator.onLine`; true for a reachable server even if the browser reports
offline (UT-069).
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
"server unreachable", which is the new semantics; `CastButton`'s local
`isConnected` is unrelated). Full frontend suite (616 tests) green.
- [x] Online behaviour is unchanged: with the flag on (its default, always so
while reachable) `get_items` keeps the offline fast-path and background
refresh (UT-067 + gate-on fall-through test).
- [~] A download queued from a greyed offline card resolves and starts on
reconnect (IT-017 — regression check, no code change; offlineCatalog
resume path untouched).
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
touched files).
## Testing
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
result as-is and does **not** query the server. Assert via a mock online repo
whose `get_items` bumps a call counter that must stay at zero.
- Gate on + empty cache still falls through to the server (guard the online path).
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
Frontend (vitest, `src/lib/**/*.test.ts`):
- **UT-069**`isConnected` follows `isServerReachable` alone: false when
unreachable with `navigator.onLine === true`; true when reachable with
`navigator.onLine === false`.
- **UT-068**`pushCatalogVisibility` resolves `serverReachable || showCatalog`
and pushes to the backend on a change of either input (extend the existing
offlineCatalog tests).
Integration (IT-016, IT-017) are documented as pending in
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
New/changed requirement code keeps its `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
(`… | DR-078`). Update the getter's tag when you expose it.
## Notes for the implementer
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
before Fix A — it is the canonical statement of the reachability model this fix
restores fidelity to.
- Fix B relies on the frontend having pushed the flag before the query runs; that
ordering already holds for the SQL gate today. No new locking.
- Another session is active in this repo (WiFi-only downloads, account menu
landed alongside this work). Check `git diff` before "repairing" unexpected
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
other new rows.
+273
View File
@@ -0,0 +1,273 @@
# Spec: Move search scope taxonomy behind the Rust boundary
**Status:** Proposed
**Scope:** Rust + Frontend. **Revises a decision in
[scoped-search.md](scoped-search.md).**
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
architecture/boundary change with **no user-visible behaviour difference**.
## Why this spec exists
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
Rust changes." That was the smallest wiring change, and it worked — but it left
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
violates the project's core boundary rule ("Svelte frontend — presentation
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
The offending knowledge lives in
[searchScope.ts](../../src/lib/utils/searchScope.ts):
```ts
const SCOPE_ITEM_TYPES = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
const GROUP_ITEM_TYPES = {
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
movies: ["Movie"], tvShows: ["Series", "Episode"],
};
```
This is a **domain definition** — "what the category *Music* means in Jellyfin's
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
creates: the day the backend starts returning a type the frontend never
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
drops it from both the query filter and the result buckets, and nothing in the
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
sources of truth that will drift.
**This must be fixed while the feature is uncommitted**, before the leak ships
baked into a released wire contract.
### What is *not* a leak (leave it alone)
Single concrete-type list pages are **not** business logic and stay as-is:
- `music.ts``["MusicAlbum"]` / `["Playlist"]`, `movies.ts``["Movie"]`,
`tv.ts``["Series"]`
- `GenericMediaListPage.svelte``[config.itemType]`
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
`PersonDetailView`
"This page shows albums" is a legitimate presentation choice expressed through a
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
taxonomy** (a semantic category → many types, defined once and reused) crosses
the line. Do **not** invent a backend enum for every list page — that is
over-abstraction, not cleaner separation.
## The boundary rule after this change
> The frontend never names a Jellyfin item type **in connection with search.**
> It sends an opaque `scope`, and receives results already sorted into labelled
> groups. The frontend owns only **group order** (presentation) and
> **rendering**.
## Design
### Rust owns scope → item-types (query side)
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
```rust
// repository/types.rs
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope {
/// The Jellyfin item types this scope requests, or None for `All`
/// (which must send NO includeItemTypes — see below).
pub fn item_types(self) -> Option<Vec<String>> {
match self {
SearchScope::All => None,
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
.into_iter().map(String::from).collect()),
SearchScope::Movies => Some(vec!["Movie".into()]),
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
}
}
}
```
`SearchOptions` gains `scope` and the search command resolves it into the
existing `include_item_types` filter **inside Rust**, before dispatching to the
online/offline paths (which already honour `include_item_types` — do not touch
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
```rust
pub struct SearchOptions {
pub limit: Option<usize>,
pub search_term: Option<String>,
pub scope: Option<SearchScope>, // NEW
// include_item_types stays for the single-type list-page callers,
// but the SEARCH command derives it from `scope` when scope is set.
}
```
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
non-search `getItems` callers. Document this so a future reader does not send
both.
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
`includeItemTypes` entirely, not send the union of every enumerated type — types
nobody listed (Person, folders) would otherwise be filtered out. This is why
`item_types()` returns `Option`, and the command must skip the filter on `None`.
### Rust owns result bucketing (result side)
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
```rust
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
```
Rust emits **every** non-empty group it can classify, in a stable canonical
order. It does **not** apply the user's ordering or drop out-of-scope groups —
those are presentation and stay frontend-side (see below). Items whose type maps
to no group are omitted from grouped output (same as today's frontend filter).
### 🔴 The `search-event` wrinkle — both payloads must change
Search returns results **twice**: the command resolves with instant local-cache
results, then the merged cache+server union arrives later via the `search-event`
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
command return value **and** the `search-event` payload must carry
`GroupedSearchResult`. If only one is converted, the instant results group and
the merged ones do not (or vice versa), and the UI flickers between shapes. This
is the single largest part of the change and the easiest to half-do.
### What the frontend keeps (all pure presentation)
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
- `SearchScope` type — now sourced from the generated bindings, mirroring the
Rust enum (delete the hand-written union).
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
unit-tested. **Stays exactly as-is.**
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
presentation.
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
- `scopeItemTypes()`, `groupItemTypes()`.
- The `.type`-inspecting body of `composeSearchGroups()`.
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
groups** — no `.type` inspection anywhere:
```ts
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
// attach labels, omit empties. No Jellyfin type vocabulary.
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
```
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
filtered the query by scope, out-of-scope groups will simply be **empty** and
drop out via the empty-omit rule — so the frontend does not strictly need
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
it as a display hint, not authority.
### Frontend call-site changes
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
event merge) is preserved.
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
client-side `composeSearchGroups(results, …)`. The store now holds grouped
results.
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
behaviour; only the type it passes to `SearchResults` changes.
## Out of scope
- Any change to online/offline `include_item_types` **filtering** — it already
works; only the *source* of the type list moves.
- Single concrete-type list pages (see "What is not a leak").
- Ranking within or across groups.
- The UX / chip behaviour / persistence mechanism — all unchanged from
[scoped-search.md](scoped-search.md).
## Acceptance criteria
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
in `searchScope.ts` or any search call path. Verify:
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
`bindings.ts`, not hand-written unions.
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
groups, same order, same empty/out-of-scope omission, offline included.
- [ ] Both the command return and the `search-event` payload carry the grouped
shape; no shape flicker between instant and merged results.
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
committed.
## Testing
**Rust** (`src-tauri`, `cargo test`):
- `SearchScope::item_types()`: each scope's list, and `All``None`.
- Search command: `scope: Music` resolves to the four music types on the query;
`scope: All` sends no `include_item_types`.
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
unknown types are dropped; groups come out in canonical order.
- The `search-event` payload is the grouped shape (guard the wrinkle).
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
outgoing options — **rewrite** to assert `scope` is sent instead.
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
extend `resolveSearchScope`, order normalize/move/reorder, and the new
compose-over-groups (order + empty-omit, no type inspection).
- `searchGroupOrder.test.ts` — unchanged.
## TRACES
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
- `SearchScope` enum + `item_types()` + search command scope resolution:
`UR-049 | DR-063` (revised — resolution now Rust-side).
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
the wire shape.
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
## Notes for the implementer
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
§Design "Scope model / Threading scope through the store," which asserted no
Rust change. Update that spec's status to note the boundary was moved, or add a
banner pointing here — do not leave the two specs contradicting silently.
- The IPC camelCase rule applies to the new enums and structs
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
`tauriIntegration`-style test if a new command is introduced.
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
types; do not hand-edit it.
- **Another Claude session may be active in these same files** (per project
memory). `git diff` before repairing anything unexpected; these search files
are exactly the ones a parallel session touched.
+202
View File
@@ -0,0 +1,202 @@
# Spec: Context-scoped search with filter chips and configurable group order
> ⚠️ **Superseded in part by
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
> presentation layer, which violates the backend/frontend boundary. The taxonomy
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
> unchanged**; only where the scope→item-type mapping and result bucketing live
> changes. Read the boundary spec before touching search code.
**Status:** Implemented (boundary revision pending — see banner above)
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
§6.3 group order, §6.4 current deviations.
## Summary
Two related changes to search:
1. **Scope** — a search started inside a library searches *that* library.
Started from Home, `/library`, or the search tab, it searches everything.
The active scope shows as a chip row under the search bar, preselected from
context and freely changeable without retyping.
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
## Motivation
Searching "office" while browsing TV currently returns music albums, because
both search entry points call the same unscoped query. The user has already
told us what they're looking at; ignoring that makes search feel indiscriminate
and pushes the relevant result below unrelated media.
## Background: what already exists
Verified in code — **most of the plumbing is already there.** This is
substantially a wiring task, not new infrastructure.
1. **`SearchOptions` already carries the filter.**
[bindings.ts](../../src/lib/api/bindings.ts) —
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
2. **Rust already honours `include_item_types` on both paths** — online
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
options mapping) and offline
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
type filter from it). **Do not add Rust code for filtering.**
3. **Per-page list search already does this correctly.**
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
the reference for the call shape, including the `requestId` handling.
4. **The gap is exactly one function.**
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
any scope. Both callers
([search/+page.svelte](../../src/routes/search/+page.svelte) and
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
it.
5. **Group order is hardcoded in markup.**
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
renders three fixed sections in source order.
6. **Frontend preferences persist via `localStorage`**, per the existing
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
command for this.
## Design
### Scope model
One `SearchScope` type, defined once and shared:
| Scope | `includeItemTypes` | Chip label |
|-------|--------------------|------------|
| `all` | *unset* | All |
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
| `movies` | `Movie` | Movies |
| `tv` | `Series`, `Episode` | TV |
`all` must send **no** `includeItemTypes` key rather than a list of every type —
the two are not equivalent for item types not enumerated here (Person, folders).
### Route → scope resolution (DR-063)
A pure function, unit-testable without a DOM:
```ts
resolveSearchScope(pathname: string): SearchScope
```
- `/library/music*``music`
- `/library/movies*``movies`
- `/library/tv*``tv`
- `/`, `/library`, `/search`, anything else → `all`
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
current route list before finalising — do not assume this table is exhaustive.
### Scope is a starting point, not a lock (DR-064)
The resolved scope sets the **initial** chip only. Once the user taps a chip,
their choice governs until they leave the search surface. Concretely: derive the
initial value from the route, hold it in component state, and do not re-derive
it on every navigation — otherwise a user who widens to All snaps back to TV.
Changing a chip re-runs the current query at the new scope. Changing the query
keeps the current scope.
### Threading scope through the store (DR-065)
Extend the store's search signature to accept an optional scope and pass
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
exactly: the `requestId` bump, the stale-response guard, the `search-event`
listener merge, the 10s timeout, and the empty-query clear path. This is an
additive parameter — no caller should break.
### Group order (DR-066, DR-067)
Persist an ordered array of group ids:
```
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
```
Rendering composes scope and order as **two independent axes**, in this order:
1. drop groups outside the active scope,
2. sort the remainder by the user's saved order,
3. omit groups that came back empty.
Scope never rewrites the saved order — narrowing to Music and back to All must
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
the worked example.
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
keyboard-operable move up/down controls with proper labels, or the setting is
unusable with a screen reader and on any pointerless input.
Unknown or missing ids in the stored array must not crash rendering — treat the
stored order as a hint, append any group it doesn't mention, and ignore ids that
no longer exist. A user upgrading from a build with fewer groups must not lose
the new ones.
## Out of scope
- Ranking *within* a group. Order is presentation-only.
- Server-side search ranking or the Jellyfin query itself.
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
is already implicitly scoped by its own `itemType`.
- Any Rust change.
## Acceptance criteria
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
- [ ] Searching from Home, `/library`, or the search tab returns all types.
- [ ] The chip row renders under the search bar on both the search page and the
in-library header search, with the context-derived chip preselected.
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
query preserves the selected chip.
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
- [ ] Result groups render in the user's configured order, with out-of-scope and
empty groups omitted and relative order preserved.
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
restarts, and ships with the documented default.
- [ ] Offline search respects scope (the offline path already filters — verify,
don't reimplement).
- [ ] `bun run check` and `bun run test` pass.
## Testing
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
- `resolveSearchScope` — pure unit tests over the route table, including the
`/library/shows/genres` case and unknown routes falling back to `all`.
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
- The compose step: scope filter + user order + empty-group omission, including
the "narrow then widen restores order" case and a stored order containing an
unknown id.
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
the existing stale-`requestId` guard still discards superseded responses.
New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
and ordered rendering `UR-050 | DR-066, DR-067`.
## Notes for the implementer
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
document is the implementation plan.
- The IPC camelCase rule applies to anything new that crosses the boundary
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
- Another session may be active in this repo. Check `git diff` before
"repairing" unexpected changes.
+233
View File
@@ -0,0 +1,233 @@
# Spec: Background audio for video playback (Android)
**Status:** Draft
**Scope:** Android only (v1). Linux noted as future work.
**Branch base:** `android-picture-in-picture`
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
## Summary
Add a per-player toggle that lets the **audio** of a video keep playing when the
app is backgrounded or the screen is locked, while **video decoding stops**.
When the app returns to the foreground, video decoding resumes from the current
audio position.
This is the audio-first counterpart to the existing Picture-in-Picture feature
(which keeps the *whole video* decoding in a floating window). The two are
mutually exclusive: enabling background audio suppresses auto-PiP.
## Motivation
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
concert films) want to lock the phone or switch apps and keep listening without
draining battery on video decode or needing a visible floating window.
## Background: how playback actually works here
Two facts drive the entire design (verified in code, not assumed):
1. **Video renders through the HTML5 `<video>` element in the WebView on both
platforms.** The native ExoPlayer *video* surface path is disabled — see the
INTERIM override in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
around the `playerPlayItem` response handling (`useHtml5Element` is forced
`true`, native backend is stopped). So "video decoding" == the WebView
`<video>` element, and the WebView is what Android suspends on background.
2. **An Android WebView `<video>` element does not keep playing audio when the
app is backgrounded / locked.** The system throttles the WebView and media
pauses. Keeping audio alive in the background requires a **native foreground
media service**, which already exists for music:
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
+
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
(ExoPlayer) + `MediaSessionCompat`.
**Therefore the design is a handoff**, not "keep the WebView alive": on
background, stop the WebView `<video>` and start audio-only playback of the same
item through the existing native ExoPlayer audio service; on foreground, hand
back to the WebView `<video>`.
This also aligns with the project's one-directional playback rule
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
player (WebView element **or** native audio service) drives position; the UI and
MediaSession consume it. The handoff is a change of *which* player is
authoritative, and must transfer position cleanly.
## User-facing behavior
### The toggle
- A toggle button in the video player controls (next to the existing PiP /
fullscreen buttons in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
Android with a native audio service available. Hidden on Linux in v1.
- State is a UI preference on the player. Consider persisting the last choice
per user (see Open Questions) — v1 may default OFF each session.
### When toggle is ON and the app goes to background / screen locks
1. Auto-PiP is suppressed (see "Interaction with PiP").
2. The WebView `<video>` is paused and its decode stopped (release the media
source so the decoder is freed, not merely `pause()`).
3. Native audio-only playback of the same item starts at the current position,
through `JellyTauPlaybackService` (foreground notification + lockscreen
controls via the existing `MediaSessionCompat`).
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
native player (existing music behavior — reused, not rebuilt).
### When toggle is ON and the app returns to foreground
1. Native audio playback stops; its final position is captured.
2. WebView `<video>` reloads/resumes at that position and continues as normal
audiovisual playback.
3. Playback state (playing/paused) is preserved across the handoff.
### When toggle is OFF (default)
Current behavior is unchanged: backgrounding video auto-enters PiP
(`onUserLeaveHint``PictureInPictureManager.enterPip`).
## Interaction with PiP
The toggle chooses one behavior or the other:
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
bridge already exists,
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
- Toggle **OFF**`setAutoEnterEnabled(true)`. Background → PiP (status quo).
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
it enabled, and re-assert the correct value whenever the toggle changes, so a
stale setting can't leak into the next player.
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
> false during normal playback. Confirm during implementation how auto-PiP is
> actually triggering today (it may rely on a different signal), because the
> background-audio handoff needs the same "is a local video active" signal to
> know it should fire. **This is a load-bearing unknown — resolve it first
> (Phase 0).**
## Technical design
### The audio-only stream
Jellyfin can transcode/stream a video item as audio-only. Add a repository
method (mirroring
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
**audio-only stream URL for a video item** at a given audio-stream index — so
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
carries over. Prefer direct-play of the audio stream where the container/codec
allows; transcode to a broadly-supported audio codec otherwise.
Position semantics must match between the WebView `<video>` timeline and the
audio stream (account for the transcoded-HLS `seekOffset` model already in the
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
### Backend command surface (Rust)
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
camelCase param rule and `Result<T, String>` convention):
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
— stop WebView authority, start native audio-only playback at position; makes
the native player authoritative. Emits state via the existing player-event
channel so MediaSession/UI stay consumers.
- `player_exit_background_audio() -> position_seconds` — stop native audio,
return final position for the WebView to resume from; restores WebView
authority.
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
than adding a parallel path.
### Android native
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
all already implemented for music).
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
one so the frontend can signal "prepare for background audio handoff" tied to
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
enter PiP; instead trigger the handoff command.
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
player event callbacks; bind locked `AutoplayDecision` to a `let` before
matching).
### Frontend (VideoPlayer.svelte)
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
- Listen for Android lifecycle background/foreground signals (via a bridge event
or existing visibility hooks) and:
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
audio).
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
returned position, restore play/pause state.
- **Follow the native-mode pitfall** (memory:
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
`onMount`. Keep the handoff logic out of that window.
- Dual-audio is the key regression risk: at every handoff exactly one of
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
*before* starting the next, mirroring the existing HLS cleanup discipline.
## Phasing
- **Phase 0 — De-risk (do first):**
- Confirm what actually triggers today's auto-PiP given video is on the
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
- Spike: obtain an audio-only stream URL for a video item and play it through
the native audio service; measure position accuracy and that WebView audio
is fully silenced (no dual audio).
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
commands + events.
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
teardown discipline.
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
carry-over, edge cases (calls, headphone unplug, autoplay-next during
background audio).
## Testing
- Rust: unit tests for the audio-only URL builder and the two commands
(`cargo test`, `bun run test:rust`).
- IPC param-naming integration tests for any new commands
(`bun run test -- tauriIntegration.test.ts`).
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
the handoff state machine (mirror the existing
`VideoPlayer.logic.test.ts`).
- Manual on-device matrix:
- toggle ON: home button → audio continues, video stops decoding; return →
video resumes at position; playing/paused preserved.
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
resumes.
- toggle OFF: background → PiP (unchanged).
- No dual audio at any transition. No audio leak after leaving the player.
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
- Autoplay-next fires correctly if an episode ends during background audio.
## Open questions
1. **Persist the toggle per user/series, or default OFF each session?**
(Recommend: remember last choice; series-level like the audio-track
preference is a nice-to-have.)
2. **Autoplay-next during background audio** — should the next episode start as
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
continue as audio-only.)
3. **Subtitles** are irrelevant in audio-only mode but must restore on
foreground — confirm they survive the `<video>` teardown/reload.
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
`onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
## Non-goals (v1)
- Linux background audio (desktop windows keep running unfocused; low value).
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
- Re-enabling the native ExoPlayer *video* surface path.
+8 -8
View File
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
## Gitea Actions Workflows
Two workflows are configured in `.gitea/workflows/`:
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
### 1. `traceability-check.yml` (Primary - Recommended)
Gitea-native workflow with:
- ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (50%)
- ✅ Modified file checking
- ✅ Artifact preservation
- ✅ Summary reports
**Runs on:** Every push and pull request
**Runs on:** Every push and pull request to `master`/`main`/`develop`
### 2. `traceability.yml` (Alternative)
GitHub-compatible workflow with additional features:
- Pull request comments with coverage stats
- GitHub-specific integrations
A second workflow, `traceability.yml`, previously duplicated this one as a
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
its only unique step (PR comments via `actions/github-script`) depended on the
GitHub REST client, which Gitea does not provide. To add PR comments, post to
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
`traceability-check.yml` rather than reviving the old file.
## What Gets Validated
+2412 -604
View File
File diff suppressed because it is too large Load Diff
+590 -99
View File
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
- Logo (links to `/library`)
- Navigation links: Home, Library, Downloads, Settings
- Search bar (inline)
- User menu: Username, Downloads icon, Logout button
- Account menu (see §1.2)
**Mobile Navigation:**
On mobile, the header contains:
- Logo
- Three-dot overflow menu button (Android-style)
- Overflow menu includes:
- Downloads
- Settings
- Sign out
- Account menu button (see §1.2)
### 1.2 Account Menu
Account-level destinations — the ones that are *about the user* rather than
about media — live behind a single **account menu**, anchored to the user's
name/avatar at the right of the header.
**Contents, in order:**
```
┌──────────────────────────┐
│ Signed in as <name> │ ← identity, not a menu item
│ <server host> │
├──────────────────────────┤
│ ⬇ Downloads │
│ ⚙ Settings │
│ ▦ Display │ ← grid/list preference (§5A.2)
├──────────────────────────┤
│ ⇥ Sign out │
└──────────────────────────┘
```
**Rules:**
- **One menu, both platforms.** Desktop and mobile show the same items in the
same order. A user who learns where Settings lives on one form factor finds
it in the same place on the other.
- **Anchored to identity.** The trigger is the username/avatar, because that is
where users look for account actions. A bare three-dot icon does not signal
"your account".
- **Sign out is separated** by a divider and placed last — it is destructive and
must not sit adjacent to routine navigation.
- **The menu is reachable from every authenticated screen**, not only from
library routes. See §1.3.
**Access Points Summary:**
- **Downloads**Desktop: nav link + icon; Mobile: overflow menu
- **Settings**Desktop: nav link; Mobile: overflow menu
- **Downloads**header icon (desktop) + account menu (both)
- **Settings**header nav link (desktop) + account menu (both)
- **Sign out** → account menu only
### 1.3 Chrome availability
The header is shared across chrome-bearing routes. Routes fall into three groups:
| Route group | Header | Bottom nav | Account menu reachable? |
|-------------|--------|------------|-------------------------|
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
| `/settings` | Own layout | No | n/a — already there |
| `/player/*`, `/login` | No | No | No (by design) |
The rule the app honours: every authenticated, non-immersive screen exposes the
account menu. Only the full-screen player and the login screen are chrome-free.
### 1.4 Known deviations
*(None — the account-menu and chrome-availability defects tracked here under
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
from every authenticated non-immersive screen via the shared `AccountMenu`, the
username/avatar is the menu trigger, desktop and mobile share one menu, and the
Display preference has a Settings entry — UR-029, §5A.4.)*
---
@@ -386,9 +439,9 @@ flowchart TB
```mermaid
flowchart TB
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
@@ -445,54 +498,352 @@ flowchart TB
---
## 6. Search Flow
## 5A. Library Page Layouts
### 6.1 Search Page Navigation
Every browse page is one of two shapes: a **card grid** or a **row list**. This
section is the rule for which shape a page takes, what a card looks like, and
what the user is allowed to change.
### 5A.1 Card shape follows the media, not the page
Card aspect ratio is a property of *what the item is*, and is never overridden
per-page. This is the single most important layout rule: a user scanning a grid
recognises content type by silhouette before reading a word.
| Item type | Aspect | Rationale |
|-----------|--------|-----------|
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
Artist cards are square but rendered **circular-masked**, so artists are
distinguishable from albums at a glance within the same music grid.
### 5A.2 Grid vs. list
```mermaid
flowchart TB
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
Page[Library browse page] --> Kind{Content kind}
ClickSearch --> SearchPage[Search Page<br/>/search]
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
SearchPage --> EmptyState{Has Query?}
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
ShowPrompt --> UserTypes[User Types in Search]
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
LiveSearch --> ShowResults
ShowResults --> UserClick{User Clicks Result}
UserClick -->|Song| PlaySong[Play Song + Queue Results]
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
Grid --> Toggle[View toggle in page header]
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
```
**Search Page Layout:**
- **Grids are the default** for anything with cover art worth scanning.
- **Lists are mandatory, not optional**, where position carries meaning —
a track's number within an album, an episode's number within a season.
A grid destroys that ordering cue, so these pages expose **no toggle**.
- **The toggle is global, not per-page.** A user who prefers dense lists
prefers them everywhere; making them re-set it on each page is friction.
The choice persists across launches.
**Responsive columns** (grid mode), tuned so cards stay large enough to read
cover art on a phone and don't become postage stamps on a desktop:
| Breakpoint | Columns |
|------------|---------|
| base (phone) | 2 |
| sm | 3 |
| md | 4 |
| lg | 5 |
| xl | 6 |
### 5A.3 What a card shows
```
┌─────────────┐
│ │ ← cover art (aspect per §5A.1)
│ artwork │ • progress bar overlay if partially played
│ │ • watched/played check if complete
│ [▶] │ • play affordance on hover/focus
└─────────────┘
Primary line ← title, truncated to one line
Secondary line ← artist / year+rating / SxEy — one line, dimmed
```
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
grows to fit its title breaks grid alignment and makes scanning harder.
- **Progress and watched state live on the artwork**, not in the text — they
must be readable while scanning, without reading.
- **Hover/focus reveals play**, so a card is both a navigation target and a
playback target without a second control competing for space at rest.
### 5A.4 Known deviations
These are places the implementation currently diverges from the rules above.
They are recorded here so the gap is explicit rather than mistaken for intent.
- **The view toggle is discoverable only on a browse page.** The preference is
already global and persisted, but the only control that sets it is the pair
of icon buttons in a library page header. Settings has no display section, so
there is nowhere to look for it. *(UR-029)*
---
## 5B. Video Detail Page Composition
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
surface renders is decided by item type plus the `?episode=` query param, and
**section order is part of the spec** — it is what makes "keep watching this
show" the path of least resistance.
### 5B.1 Which surface renders
```mermaid
flowchart TB
Nav[Navigate to /library/&#91;id&#93;] --> Type{Item type}
Type -->|Person| Person[PersonDetailView]
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
Type -->|Series| Ep{?episode= param<br/>present?}
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
Ep -->|No| Series[Series detail<br/>§5B.4]
Focus -->|Back to series| Series
Series -->|Click episode| Focus
```
An episode is **never** browsed as a bare `Episode` item page. Clicking an
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
episode is always shown in the context of its series and the series' full
episode list is already loaded.
### 5B.2 Episode Focus View — section order
**The next episodes appear directly below the current episode, above cast and
similar shows.** Nothing may be inserted between the episode hero and the
episode strip.
```
┌─────────────────────────────────────────────────┐
│ [←] │
│ ┌───────────────────────────────────────────┐ │
│ │ episode backdrop │ │
│ │ Series Name │ │ ← 1. HERO
│ │ Episode Title │ │
│ │ S2E4 • 48m • ★8.1 │ │
│ │ Overview… │ │
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
│ │ [▶ Play] │ │
│ └───────────────────────────────────────────┘ │
│ │
│ More Episodes │ ← 2. EPISODE STRIP
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
│ │ ││NOW ││ ││ │ │
│ └──────┘└──────┘└──────┘└──────┘ │
│ │
│ Cast │ ← 3. CAST
│ ( ○ )( ○ )( ○ )( ○ ) │
│ │
│ More Like This │ ← 4. SIMILAR
│ ┌────┐┌────┐┌────┐┌────┐ │
└─────────────────────────────────────────────────┘
```
**Rules for the episode strip:**
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
between the current episode and every other section; cast and related
content are *below* it, never above.
- **Window, not full list.** The strip shows a window around the current
episode — roughly 3 before and 6 after — so the immediate next episodes are
visible without scrolling, and earlier ones remain reachable by scrolling
left. It is horizontally scrollable, not a wrapped grid.
- **Forward bias.** More episodes are shown *after* the current one than
before it: the dominant intent on this screen is "watch the next one."
- **The current episode is present and marked.** It renders in-strip with a
"NOW" badge and a highlight ring, and is not clickable. It anchors the
user's position in the season rather than being hidden.
- **Cross-season continuity.** The window spans the whole series in episode
order, so the strip runs past a season boundary into the next season's first
episodes rather than dead-ending at the end of a season.
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
progress bar when partially watched, and a watched checkmark when complete.
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
not start playback. Playback starts only from the hero's Play button.
### 5B.3 Movie detail — section order
```
Hero (poster, title, metadata, Play / Download / Favorite)
→ Crew links (Directed by / Written by / Music by)
→ Genre tags
→ Cast
→ More Like This
```
A movie has no continuation set, so cast follows the hero directly.
### 5B.4 Series detail — section order
```
Hero (poster, title, metadata, Play / Download)
→ Crew links
→ Genre tags
→ Seasons + episodes (per-season sections)
→ Cast
→ More Like This
```
The same principle as §5B.2: **episodes come before cast and similar shows.**
The reason a user opens a series page is to pick an episode; discovery content
is secondary and sits underneath.
---
## 6. Search Flow
Search is **context-scoped**: what you are looking at when you start a search
determines what the search covers. A search begun inside the Music library
searches music. A search begun from Home or the top-level library page searches
everything. The scope is always shown, and always overridable.
### 6.1 Scope is inherited from context
```mermaid
flowchart TB
Start[User starts a search] --> Where{Where from?}
Where -->|Home &#40;/&#41;| All[Scope: All]
Where -->|Library root &#40;/library&#41;| All
Where -->|Search tab| All
Where -->|Inside Music| Music[Scope: Music]
Where -->|Inside Movies| Movies[Scope: Movies]
Where -->|Inside TV| TV[Scope: TV]
All --> Chips[Filter chips shown<br/>All chip selected]
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
Movies --> Chips2
TV --> Chips2
Chips --> Results[Results, grouped by type]
Chips2 --> Results
Results --> Change{User taps a chip}
Change --> Rescope[Re-run search at new scope<br/>query preserved]
Rescope --> Results
```
**Rules:**
- **Context sets the *initial* chip, never a locked filter.** Entering search
from TV preselects the TV chip; the user can tap "All" to widen without
retyping the query. Scope is a starting point, not a cage.
- **Home, `/library`, and the search tab all start at "All".** These are the
places a user has expressed no narrower intent.
- **Changing scope preserves the query** and re-runs the search. Changing the
query preserves the scope.
- **Scope maps to item types**, resolved at the point of search:
| Chip | `includeItemTypes` |
|------|--------------------|
| All | *(unset — every type)* |
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
| Movies | `Movie` |
| TV | `Series`, `Episode` |
- **Chips render under the search bar**, on both the dedicated search page and
the in-library header search. They are horizontally scrollable if they
overflow, never wrapped onto a second row.
### 6.2 Search page layout
```
┌─────────────────────────────────────────┐
│ [🔍 Search...] [✕]
│ [🔍 Search...] [✕] │
│ │
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
│ │
│ Songs ──────────────────────────── │
│ ♪ Song Title - Artist 3:45 │
│ ♪ Song Title - Artist 4:12 │
│ See all (23) │
│ ♪ Song Title - Artist 3:45 │
│ ♪ Song Title - Artist 4:12 │
│ See all (23)
│ │
│ Albums ─────────────────────────── │
│ [Album Cover] Album Title │
[Album Cover] Album Title
│ See all (8) │
│ [Cover] Album Title
See all (8)
│ │
│ Artists ────────────────────────── │
[Photo] Artist Name
│ See all (5) │
( Photo ) Artist Name │
│ See all (5)
└─────────────────────────────────────────┘
```
- Results stay **grouped by type** even when a scope is selected — a Music
search still separates Songs / Albums / Artists.
- Each group shows a bounded preview with a **See all (n)** affordance rather
than an unbounded list, so no single type can bury the others.
- Live search is **debounced** as the user types; a query that becomes empty
clears results rather than searching for the empty string.
### 6.3 Result group order is user-configurable
Which *kind* of thing a user is usually searching for is personal: a
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
than guessing, the group order is a setting.
```mermaid
flowchart TB
Settings[Settings → Search] --> List[Draggable list of result groups]
List --> Drag[User drags a group up or down]
Drag --> Persist[Order persisted]
Persist --> Render[Rendering a result set]
Scope[Active scope chip §6.1] --> Render
Render --> Filter[1 - Drop groups outside the active scope]
Filter --> Sort[2 - Sort remaining groups by user order]
Sort --> Prune[3 - Omit groups with no results]
Prune --> Show[Render]
```
**Scope and order compose — they are two independent axes.** The scope chip
decides *which* groups are eligible; the settings list decides *what sequence*
the eligible ones appear in. Order is preserved as a relative ranking, never
renumbered per scope:
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
groups keep their relative order.
- Scope **All** with the same setting renders all five in exactly that order.
- **Changing scope never rewrites the saved order.** A user who narrows to
Music and back to All sees their original arrangement intact.
**Rules:**
- **Drag and drop to reorder**, in a settings list showing every result group
(Songs, Albums, Artists, Movies, TV Shows).
- **The order applies to grouped results everywhere** — the search page and
the in-library header search alike.
- **Order is presentation-only.** It never changes which results are returned
or how they are ranked *within* a group, only the sequence groups appear in.
- **Empty groups are skipped, not gapped.** A group with no results is omitted
entirely; it does not reserve space or leave a stray heading.
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
so the setting is an adjustment, never a prerequisite.
- **Keyboard/accessible reordering must exist** alongside dragging — a
drag-only control is unusable with a screen reader or without a pointer.
### 6.4 Known deviations
Recorded so the gap between this spec and the build is explicit.
- **Scope is not implemented.** The in-library header search calls the same
unscoped query as the global search page, so searching inside TV returns
music. The backend already accepts `includeItemTypes` on both the online and
offline paths, and the per-page list search already uses it — only the global
path ignores it. *(UR-049)*
- **Filter chips do not exist** on either search surface. *(UR-049)*
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
with no setting. *(UR-050)*
---
## 7. Download Flows
@@ -532,68 +883,166 @@ States:
5. [⏸] Paused - Yellow pause icon
```
### 7.2 Managing Downloads Page
### 7.2 Downloads = a browsable offline library, not a flat list
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
*the library, filtered to what's on the device*. A user who has downloaded three
seasons of a show and two albums thinks in terms of shows and albums, not
seventy-odd individual episode/track transfers. So the primary Downloads surface
**reuses the library browse screens**, scoped to downloaded content, and keeps
the transfer-progress list as a secondary "Transfers" view for the *act* of
downloading.
This splits one overloaded page into two clear jobs:
| Surface | Answers | Reuses |
|---------|---------|--------|
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
```mermaid
flowchart TB
User[User] --> NavChoice{Navigation Path}
Nav[Open Downloads] --> Downloads[/downloads]
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
NavChoice -->|Direct| TypeURL[Type /downloads]
Downloads --> View{View}
View -->|Downloaded &#40;default&#41;| Browse[Offline library browse]
View -->|Transfers| Activity[Transfer activity list]
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
HeaderIcon --> DownloadsPage
TypeURL --> DownloadsPage
Browse --> Libs[Libraries — only those with<br/>downloaded content]
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
Grid --> Detail[Detail page<br/>same as online]
Detail --> Play[Play from local file]
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
ShowTabs --> ActiveTab{Active Tab}
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
ShowActive --> UserAction1{User Action}
UserAction1 -->|Pause| PauseDownload[Pause Download]
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
ShowCompleted --> UserAction2{User Action}
UserAction2 -->|Play| PlayOffline[Play from Local File]
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
Rows --> Ctl[Pause / Resume / Cancel / Retry]
```
**Navigation to Downloads:**
- **Desktop:** Click "Downloads" link in header navigation
- **All screen sizes:** Click download icon (⬇) button in header user menu
- **Direct:** Navigate to `/downloads` route
**Why reuse the library screens (not a bespoke list):**
- **One mental model.** Browsing offline should feel identical to browsing
online — same grids, same card shapes, same detail pages, same play action.
The only difference is *what's present*, not *how it looks*.
- **It already works in the backend.** The offline repository's `get_items`
already returns downloaded items **plus** their containers (an album with any
downloaded track, a series/season with any downloaded episode). That is a
browsable tree today — see §7.4.
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
browsable library does not.
### 7.3 The Downloaded browse surface
**Downloads Page Layout:**
```
┌─────────────────────────────────────────┐
[←] Downloads │
[Active (3)] [Completed (12)]
─ Downloading ────────────────────
Album Cover Album Title │
Artist Name
[████████░░] 80%
[⏸ Pause] [✕ Cancel]
│ │
Album Cover Album Title
Artist Name
[██░░░░░░░░] 20%
│ [⏸ Pause] [✕ Cancel] │
│ │
│ ─ Queued ───────────────────────── │
│ │
│ Album Cover Album Title │
│ Artist Name │
│ Waiting... │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────
│ Downloads
( Downloaded ) ( Transfers ) ← view switch
[~ 3.4 GB on device · 12 items] Manage ▸ ← storage summary
Music ← only libraries that
┌────┐┌────┐┌────┐ │ have downloaded content
│alb ││alb ││art │
└────┘└────┘└────┘
TV
┌────┐┌────┐
│show││show│
└────┘└────┘
└─────────────────────────────────────────────┘
```
**Rules:**
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
music is downloaded, only Music appears.
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
browse is the same components with an offline-scoped data source, never a
parallel re-implementation.
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
- **Disk usage is shown where the user already looks**, in familiar units — see
§7.3.1.
- **Play always plays the local file** here; nothing on this surface streams.
- **Remove is available at every level** — item, album/season, series — and
states clearly what it frees. Removing the last downloaded child of a
container removes the container from the browse.
- **This surface works identically online and offline.** It is "what's on the
device," a question whose answer does not depend on connectivity. It must not
wait for, or be emptied by, server reachability.
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
Users want to know what each thing costs on disk, but that information has to
feel like the storage views they already know (phone Settings → Storage, a
file browser), not a developer's byte dump.
- **Size rides along with the item, on the card and the detail page** — a small
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
report" screen the user has to go find.
- **Containers show their total.** A series shows the sum of its downloaded
episodes; an album the sum of its tracks; a season its own subtotal. The
number a user sees on the "Breaking Bad" card is what removing it frees.
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
and use it everywhere. Show 23 significant figures (`1.2 GB`, not
`1,283,048,192 bytes` and not `1.28394 GB`).
- **A single device total sits at the top** of the Downloaded surface
(`3.4 GB on device · 12 items`) so the headline number is answered before the
user scans. It reconciles with the sum of what's listed.
- **Remove restates the reclaim** in the same units at the point of action
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
legible exactly when the user decides.
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
what to clear) but is not required for v1.
The bytes-on-disk per item are a backend fact (the download manager writes the
files and can stat them); this is a display and aggregation task, not new
tracking. See §7.7 deviations for what's missing today.
### 7.4 Transfers (activity) view
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
It is about *transfers in flight*, so it shows only rows that are doing or
waiting to do something:
- **States:** downloading (with progress), queued, paused, failed,
waiting-for-WiFi (§7.5).
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
and auto-pump are backend concerns and are not surfaced as manual controls.
- **Completed transfers fall off this view** once done — the finished item lives
in Downloaded, not here. A transient "just finished" confirmation is fine; a
permanent completed-list is not (that's what Downloaded is for).
- **Empty state** points at the library: "Nothing downloading. Browse your
library and tap download to save media for offline."
### 7.5 Navigation & entry points
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
link/icon → `/downloads`.
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
and should draw attention (badge/count) only while transfers are active.
- Initiating a download is unchanged (§7.1): the download button lives on
item/album/series detail pages. The Downloads page manages and browses; it is
not where you start a download.
### 7.7 Known deviations
Recorded so the gap between this spec and the build is explicit.
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
row per individual transfer with no browsing, grouping, or reuse of the
library screens. Completed downloads never collapse into their album/series.
*(UR-055)*
- **No offline-scoped browse entry point exists in the client.** All browsing
goes through the hybrid repository, which merges cache **and** server; there is
no way to ask for "downloaded content only" as a browse surface. The offline
repository supports it (§7.2) but is not reachable independently. *(UR-055,
DR-082)*
- **The "on device" storage summary and per-container remove** are absent from
the completed list. *(UR-055, UR-056)*
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
no size; there is no device total, no container subtotal, and Remove does not
state what it frees. *(UR-056)*
---
## 8. Settings & Account Flows
@@ -627,6 +1076,13 @@ flowchart TB
- **Mobile:** Click three-dot overflow menu → Select "Settings"
- **Direct:** Navigate to `/settings` route
**Settings apply instantly.** Every control on the Settings page persists the
moment the user changes it — toggling a switch, picking a level, or releasing a
slider writes that setting immediately. There is **no "Save" button** and no
save/dirty state to reason about; leaving the page never risks losing a change.
Sliders update their live readout while dragging but only persist on release
(`change`, not each `input` tick) to avoid flooding the backend.
### 8.2 Logout Flow
```mermaid
@@ -690,24 +1146,59 @@ flowchart TB
└─────────────────────────────────────────┘
```
### 9.2 Video Playback in Background
### 9.2 Video Playback in Background (Android — PiP & Background Audio)
Leaving the app while a **local video** is playing does not simply pause it.
What happens depends on which background behaviour is active. The two are
**mutually exclusive**, and both apply **only to locally-rendering video**
audio-only playback, library/menu browsing, and remote/cast sessions never
trigger PiP (see decision gate below).
```mermaid
flowchart TB
VideoPlaying[Video Playing] --> Background{User Action}
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
Background -->|Home Button| AutoPause[Automatically Pause]
Background -->|Screen Lock| AutoPause
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]
AutoPause --> SaveProgress[Save Progress]
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
Gate -->|Yes| Mode{Background mode armed?}
ShowNotification --> UserReturn{User Returns?}
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
UserReturn -->|Later| KeepPaused[Video Remains Paused]
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> &#40;reflects live player state&#41;]
ResumeVideo --> AskResume[Resume from Saved Position]
PiPWindow --> PiPReturn{User action}
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
PiPReturn -->|Close window| Stop[Playback stops]
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
```
**Key rules:**
- **Video-only gate.** Auto-PiP is guarded by the native `canEnterPip` check
(local video surface actively rendering). Audio playback and menu/library
browsing background normally; remote/cast sessions render nothing locally, so
a PiP window would be an empty box and is refused. *(UR-041, IR-026)*
- **Only one background behaviour at a time.** The background-audio toggle
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
audio service *or* floated in PiP, never both.
- **PiP controls track the player.** The play/pause RemoteAction in the PiP
window reflects the live player state and updates on every playback-state
change, not only when the button is pressed. *(DR-053)*
- **Non-disruptive transition.** ExoPlayer keeps rendering into the same
surface across enter/exit, so entering or leaving PiP never interrupts the
video; on exit the surface is re-fit to full-screen bounds. *(DR-053)*
**PiP window (Android):**
```
┌───────────────────┐
│ │
│ ▶ video frame │
│ │
│ [⏸] │ ← play/pause RemoteAction
└───────────────────┘
sized to the video's aspect ratio
```
---
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.0.16",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -17,6 +17,7 @@
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
"test:all": "./scripts/test-all.sh",
"test:rust": "./scripts/test-rust.sh",
"check:boundary": "bash scripts/check-frontend-boundary.sh",
"android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release",
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
@@ -28,7 +29,8 @@
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
"traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"release:notes": "bun run scripts/release-notes.ts"
},
"license": "MIT",
"dependencies": {
+3
View File
@@ -44,6 +44,9 @@ bun run build
# Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then
# Configure release signing from .env (single source of truth). Must run
# after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh
echo "📦 Building release APK..."
bun run tauri android build --apk true
else
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
#
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
# the frontend is presentation-only and the Rust backend owns domain logic —
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
# that motivated this check.
#
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
# targets the one machine-detectable signature of the leak class — a *query* that
# names a multi-type category — and defers everything subtler to the human
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
# does not mean the boundary is respected; it means the crudest violation isn't
# present.
#
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
# Jellyfin types, which is domain knowledge the backend should own. Single-type
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
# and is not matched.
#
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
set -euo pipefail
cd "$(dirname "$0")/.."
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
# signal to push taxonomy into Rust, not to keep appending here.
ALLOWLIST=(
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
# two-type filmography query with no category-configuration behind it. Tracked
# as acceptable pending any person-scope work; revisit if it grows.
"src/lib/components/library/PersonDetailView.svelte"
)
is_allowed() {
local file="$1"
for allowed in "${ALLOWLIST[@]}"; do
[[ "$file" == "$allowed" ]] && return 0
done
return 1
}
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
# The comma inside the brackets is what makes it multi-type.
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
# Collect hits, excluding tests and the allowlist.
violations=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
file="${line%%:*}"
case "$file" in
*.test.*) continue ;;
esac
if is_allowed "$file"; then
echo " ⏭️ allowlisted: $line"
continue
fi
violations+="$line"$'\n'
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
if [[ -n "$violations" ]]; then
echo ""
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
echo " send an opaque scope and let the backend expand it to item types."
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
echo ""
echo "$violations" | sed 's/^/ /'
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
exit 1
fi
echo "✅ No multi-type taxonomy queries in the frontend."
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
echo " the real gate for subtler leaks.)"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bun
/**
* release-notes.ts turn a commit range into capability-level release notes
* using the TRACES graph instead of raw commit subjects.
*
* Usage:
* bun run scripts/release-notes.ts [<range>]
* bun run scripts/release-notes.ts v0.0.15..HEAD
*
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
*
* How it works:
* 1. `git diff --name-only <range>` files the range changed.
* 2. Read each changed file's `TRACES:` comments requirement IDs.
* 3. Resolve IDs to descriptions from docs/requirements.md.
* 4. Group: UR Features, DR/IR Improvements. Deduped, so many commits
* touching one requirement collapse to one line.
*
* This is a drafting aid for docs/release-checklist.md review the output,
* it does not invent descriptions for untraced changes (those are listed
* separately so nothing is silently dropped).
*/
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
function sh(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function defaultRange(): string {
try {
const tag = sh("git describe --tags --abbrev=0");
return `${tag}..HEAD`;
} catch {
return ""; // no tags: fall through to whole-history diff
}
}
/** Map requirement ID → human description, parsed from docs/requirements.md. */
function loadRequirementDescriptions(): Map<string, string> {
const map = new Map<string, string>();
const text = readFileSync("docs/requirements.md", "utf8");
for (const line of text.split("\n")) {
const m = line.match(REQ_ROW_RE);
// First definition wins: the descriptive tables come before the later
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
}
return map;
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
}
/** Collect requirement IDs referenced by TRACES comments in the given files. */
function idsFromFiles(files: string[]): Set<string> {
const ids = new Set<string>();
for (const file of files) {
let content: string;
try {
content = readFileSync(file, "utf8");
} catch {
continue;
}
for (const trace of content.matchAll(TRACE_RE)) {
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
}
}
return ids;
}
function main() {
const range = process.argv[2] ?? defaultRange();
const descriptions = loadRequirementDescriptions();
const files = changedFiles(range);
const ids = idsFromFiles(files);
const features: string[] = []; // UR
const improvements: string[] = []; // DR / IR
const unknown: string[] = []; // traced but not in requirements.md
for (const id of [...ids].sort()) {
const desc = descriptions.get(id);
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
if (!desc) {
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
continue;
}
const line = `- ${desc} (${id})`;
if (id.startsWith("UR")) features.push(line);
else improvements.push(line);
}
const header = range || "(entire history — no tags found)";
const out: string[] = [`## Release notes — ${header}`, ""];
if (features.length) out.push("### ✨ Features", ...features, "");
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
if (unknown.length)
out.push(
"### ⚠️ Traced IDs missing from requirements.md",
...unknown.map((id) => `- ${id}`),
"",
);
const untraced = files.filter((f) => {
try {
return !/TRACES:/.test(readFileSync(f, "utf8"));
} catch {
return false;
}
});
if (untraced.length)
out.push(
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
...untraced.map((f) => `- ${f}`),
"",
);
if (!features.length && !improvements.length)
out.push("_No traced requirements in this range._", "");
console.log(out.join("\n"));
}
main();
+41
View File
@@ -41,6 +41,19 @@ if [ -f "$APP_GRADLE_SRC" ]; then
echo " Copied: app/build.gradle.kts"
fi
# AndroidManifest.xml. `tauri android init` regenerates gen/android from
# tauri.conf.json and would drop our hand-maintained entries (media playback
# service + permissions, hardware acceleration, picture-in-picture attributes
# on MainActivity), so this tracked copy is the source of truth and must be
# restored after a regen. Gradle reads ONLY the gen/ copy - there is no
# manifest-merger hook here, so this must be the complete manifest.
MANIFEST_SRC="$PROJECT_ROOT/src-tauri/android/src/main/AndroidManifest.xml"
MANIFEST_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/AndroidManifest.xml"
if [ -f "$MANIFEST_SRC" ]; then
cp "$MANIFEST_SRC" "$MANIFEST_DST"
echo " Copied: app/src/main/AndroidManifest.xml"
fi
# Custom ProGuard/R8 keep rules. Required for minified release builds:
# the player/ and security/ Kotlin classes are loaded by name via JNI from
# Rust, so R8 can't see the references and would strip them without this.
@@ -65,6 +78,34 @@ if [ -d "$RES_SRC" ]; then
cp "$dir"/* "$RES_DST/$name/"
echo " Copied res: $name"
done
# values/ (themes.xml): status-bar styling that `tauri android init` does
# not generate. Previously this directory was tracked but never copied, so
# the theme customizations below never reached a build.
if [ -d "$RES_SRC/values" ]; then
mkdir -p "$RES_DST/values"
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
echo " Copied res: values"
fi
# We ship only the color adaptive icon (background + foreground). Drop any
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
# render well, and our adaptive-icon xml no longer references it, so a stray
# ic_launcher_monochrome.png would just be dead weight.
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
# as API-qualified VECTOR drawables:
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
# Because drawable-v24 is a more specific match than our unqualified
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
# the green square robot instead of our jellyfish. Remove them so the
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
# to the real committed PNGs.
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_background.xml
fi
echo "✓ Android sources synced successfully"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
#
# .env is the single source of truth for local release signing. `tauri android
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
# from .env before every release build (this is the local mirror of what the CI
# workflow does from Gitea secrets).
#
# Required .env vars:
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE not found — cannot configure release signing." >&2
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
exit 1
fi
# Load .env without leaking it into the caller's environment beyond what we need.
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
exit 1
fi
mkdir -p "$(dirname "$PROPS")"
umask 077
cat > "$PROPS" <<EOF
storeFile=$ANDROID_KEYSTORE_FILE
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
@@ -9,6 +9,16 @@
-keep class com.dtourolle.jellytau.player.** { *; }
-keep class com.dtourolle.jellytau.security.** { *; }
# Picture-in-picture is driven from the WebView through an
# @JavascriptInterface bridge, so the only references to these methods live
# in JavaScript. R8 sees them as unused and would strip them, silently
# breaking the PiP button in release builds only.
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-39
View File
@@ -1,39 +0,0 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
+66 -2
View File
@@ -1,5 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Authoritative AndroidManifest for JellyTau.
NOTE: this is NOT a manifest-merger fragment. Gradle only ever reads
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
regenerates that file from tauri.conf.json - dropping everything below.
scripts/sync-android-sources.sh copies this file over the generated one,
so this is the full manifest and the single source of truth.
(An earlier version of this file was a partial <application> fragment on the
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
declared never reached any built APK. It is folded in properly below.)
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Enable hardware acceleration for video playback performance -->
<application android:hardwareAccelerated="true" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.jellytau"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Media playback service for lockscreen controls -->
<service
android:name="com.dtourolle.jellytau.player.JellyTauPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -22,6 +22,34 @@ class MainActivity : TauriActivity() {
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
/**
* Coarse override for whether backgrounding the app should auto-enter PiP.
*
* This is NOT what excludes audio/browsing/cast from PiP that is the
* PictureInPictureManager.canEnterPip guard, which requires a local video
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
* flag is only toggled by the background-audio feature (via
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
* auto-PiP stay mutually exclusive.
*/
@Volatile
private var autoEnterPipEnabled = true
/**
* Whether the user armed background-audio mode on the current video (UR-040).
* When true, leaving the app hands audio off to the native ExoPlayer audio
* service (frontend-driven) instead of entering PiP, and video decode stops.
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
*/
@Volatile
private var backgroundAudioEnabled = false
/**
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
* can dispatch DOM events into it (native frontend signalling).
*/
private var mediaWebView: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -37,6 +65,80 @@ class MainActivity : TauriActivity() {
configureWebViewForMedia()
}
/**
* Called when the user leaves the app via Home or the gesture equivalent
* (but NOT via Back). This is the standard hook for auto-entering PiP so
* video keeps playing in a floating window instead of being backgrounded.
*
* TRACES: UR-041 | IR-026 | DR-053
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Never enter PiP while background-audio mode is armed — the two are mutually
// exclusive (the handoff runs from onStop instead).
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
PictureInPictureManager.canEnterPip(this)) {
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
PictureInPictureManager.enterPip(this)
}
}
/**
* The app is no longer visible (Home, app switch, or screen lock). When
* background-audio mode is armed, tell the frontend to hand video playback off
* to the native audio service. onStop (rather than onUserLeaveHint) is used
* because it fires on screen-lock too, which is the primary use case (UR-040).
*
* TRACES: UR-040 | IR-025
*/
override fun onStop() {
super.onStop()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-background")
}
}
/** The app is visible again — tell the frontend to resume WebView video. */
override fun onStart() {
super.onStart()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-foreground")
}
}
/**
* Dispatch a DOM CustomEvent into the WebView (native frontend). Mirrors the
* evaluateJavascript pattern already used to unmute video elements. Posted to
* the WebView thread; safe no-op if the WebView isn't found yet.
*/
private fun dispatchWebEvent(name: String) {
val webView = mediaWebView ?: run {
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
return
}
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
android.util.Log.d("MainActivity", "Dispatched web event: $name")
}
}
override fun onDestroy() {
NetworkTypeMonitor.stopWatching(this)
super.onDestroy()
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: android.content.res.Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
android.util.Log.d("MainActivity", "PiP mode changed: $isInPictureInPictureMode")
PictureInPictureManager.onPipModeChanged(this, isInPictureInPictureMode)
}
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
@@ -55,6 +157,7 @@ class MainActivity : TauriActivity() {
}
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@@ -70,6 +173,81 @@ class MainActivity : TauriActivity() {
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
// methods are invoked on a WebView binder thread.
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun enterPip() {
handler.post { PictureInPictureManager.enterPip(this@MainActivity) }
}
/** Whether the PiP button should be offered in the player UI at all. */
@JavascriptInterface
fun isSupported(): Boolean {
return PictureInPictureManager.isPipSupported(this@MainActivity)
}
/** Whether entering PiP would work right now (local video playing). */
@JavascriptInterface
fun canEnterPip(): Boolean {
return PictureInPictureManager.canEnterPip(this@MainActivity)
}
/** Let the frontend opt out of auto-PiP (e.g. while casting). */
@JavascriptInterface
fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
// Add JavaScript interface for background-audio mode (UR-040). The frontend
// arms/disarms it via the player toggle; the Activity uses the flag in its
// lifecycle overrides to decide between the audio handoff and PiP.
webView.addJavascriptInterface(object : Any() {
/** Frontend arms/disarms background-audio mode for the current video. */
@JavascriptInterface
fun setEnabled(enabled: Boolean) {
backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
}
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
// Network transport reporting for the WiFi-only download gate (UR-053).
// The frontend polls these on demand and re-pumps the download queue when
// the 'jellytau-network-changed' event fires.
webView.addJavascriptInterface(object : Any() {
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
@JavascriptInterface
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
/** Whether the active network is unmetered. */
@JavascriptInterface
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
/** Whether downloads may run given the wifi-only preference. */
@JavascriptInterface
fun isAcceptable(wifiOnly: Boolean): Boolean =
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
/** Whether native network detection is available at all (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidNetworkType")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
// Push network changes into the WebView so a queue blocked on "waiting for
// WiFi" resumes the moment an acceptable network appears.
NetworkTypeMonitor.startWatching(this) {
dispatchWebEvent("jellytau-network-changed")
}
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -93,7 +271,6 @@ class MainActivity : TauriActivity() {
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
setRenderPriority(WebSettings.RenderPriority.HIGH)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
@@ -0,0 +1,152 @@
package com.dtourolle.jellytau
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Reports the *kind* of network the device is on, so downloads can be gated on
* "unmetered only" (the WiFi-only setting).
*
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
* answers a different question: whether the Jellyfin *server* is reachable,
* derived from real request outcomes. Reachability and transport type are
* orthogonal you can be on WiFi with a dead server, or on cellular with a
* perfectly reachable one.
*
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
* and we report UNKNOWN (which the gate treats as "not acceptable" when
* wifi-only is on, failing closed rather than burning mobile data).
*
* TRACES: UR-053 | DR-074
*/
object NetworkTypeMonitor {
private const val TAG = "NetworkTypeMonitor"
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
const val TYPE_NONE = "none"
const val TYPE_WIFI = "wifi"
const val TYPE_ETHERNET = "ethernet"
const val TYPE_CELLULAR = "cellular"
const val TYPE_OTHER = "other"
const val TYPE_UNKNOWN = "unknown"
private var callback: ConnectivityManager.NetworkCallback? = null
/** Invoked on any network change; set by [startWatching]. */
@Volatile
private var onChange: (() -> Unit)? = null
private fun connectivityManager(context: Context): ConnectivityManager? =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
/**
* Current transport type of the active network.
*
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
* distinguish "definitely offline" from "couldn't tell".
*/
fun currentType(context: Context): String {
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
val network = cm.activeNetwork ?: return TYPE_NONE
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
return when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
else -> TYPE_OTHER
}
}
/**
* Whether the active network is unmetered.
*
* This is the bit that actually matters for the WiFi-only gate: a phone
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
* cellular data the setting exists to protect. Checking NOT_METERED rather
* than the transport alone means tethering doesn't quietly burn a data plan.
*/
fun isUnmetered(context: Context): Boolean {
val cm = connectivityManager(context) ?: return false
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/**
* Whether downloads may run right now given the wifi-only preference.
*
* Ethernet counts as acceptable (it is unmetered in practice and is what
* Android TV devices use). Cellular never does. When wifi-only is off this is
* always true the gate simply isn't engaged.
*/
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
if (!wifiOnly) return true
val type = currentType(context)
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
return isUnmetered(context)
}
/**
* Register a callback that fires whenever the network changes, so a blocked
* download queue can be re-pumped the moment an acceptable network appears.
* Without this the queue would stall until some unrelated event pumped it.
*
* Idempotent: a second call replaces the previous callback.
*/
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
val cm = connectivityManager(context) ?: run {
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
return
}
stopWatching(context)
onChange = onNetworkChanged
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
android.util.Log.d(TAG, "Network available")
onChange?.invoke()
}
override fun onLost(network: Network) {
android.util.Log.d(TAG, "Network lost")
onChange?.invoke()
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
// Fires when e.g. metered-ness flips without the network itself changing.
onChange?.invoke()
}
}
try {
cm.registerNetworkCallback(request, cb)
callback = cb
android.util.Log.d(TAG, "Network callback registered")
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to register network callback", e)
}
}
/** Unregister the network callback, if one is active. */
fun stopWatching(context: Context) {
val cb = callback ?: return
val cm = connectivityManager(context)
try {
cm?.unregisterNetworkCallback(cb)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to unregister network callback", e)
}
callback = null
onChange = null
}
}
@@ -0,0 +1,312 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Rational
import android.view.ViewGroup
import android.webkit.WebView
import androidx.annotation.RequiresApi
import com.dtourolle.jellytau.player.JellyTauPlayer
/**
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
*
* TRACES: UR-041 | IR-026 | DR-053
*
* PiP shrinks the whole Activity into a floating window, so the only thing that
* should remain visible is the video SurfaceView that [VideoOverlayManager]
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
* hidden for the duration - it is opaque and sits *above* the surface, so
* leaving it visible would occlude the video entirely.
*
* Playback itself is untouched: ExoPlayer keeps rendering into the same surface
* across the transition, so entering and leaving PiP never interrupts the video.
*/
object PictureInPictureManager {
private const val TAG = "PictureInPictureManager"
/** Action for the play/pause RemoteAction shown inside the PiP window. */
private const val ACTION_MEDIA_CONTROL = "com.dtourolle.jellytau.PIP_MEDIA_CONTROL"
private const val EXTRA_CONTROL_TYPE = "control_type"
private const val CONTROL_PLAY = 1
private const val CONTROL_PAUSE = 2
/** Request codes must differ per action or the PendingIntents collapse into one. */
private const val REQUEST_PLAY = 101
private const val REQUEST_PAUSE = 102
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app.
*/
fun isPipSupported(activity: Activity): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.packageManager.hasSystemFeature(
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
)
}
/**
* Whether entering PiP right now makes sense: a native video must actually
* be playing locally. Audio-only playback and remote/cast sessions render
* nothing on this device, so a PiP window would be an empty black box.
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
return try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
}
/**
* Enter picture-in-picture, sizing the window to the video's aspect ratio.
*
* @return true if the system accepted the transition.
*/
fun enterPip(activity: Activity): Boolean {
if (!canEnterPip(activity)) {
android.util.Log.d(TAG, "Not entering PiP: no local video playing")
return false
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return try {
val params = buildParams(activity)
val entered = activity.enterPictureInPictureMode(params)
android.util.Log.d(TAG, "enterPictureInPictureMode returned $entered")
entered
} catch (e: Exception) {
// IllegalStateException here means PiP is disallowed (e.g. the user
// turned it off in system settings). Never crash over it.
android.util.Log.e(TAG, "Failed to enter PiP", e)
false
}
}
/**
* Build PiP params: aspect ratio from the current video, plus a play/pause
* RemoteAction reflecting the live playback state.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildParams(activity: Activity): PictureInPictureParams {
val builder = PictureInPictureParams.Builder()
aspectRatioFor()?.let { builder.setAspectRatio(it) }
builder.setActions(listOf(buildPlayPauseAction(activity)))
return builder.build()
}
/**
* The video's aspect ratio, clamped to the range Android accepts.
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content.
*/
private fun aspectRatioFor(): Rational? {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return null
}
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width
val height = surface.height
if (width <= 0 || height <= 0) return null
val ratio = width.toDouble() / height.toDouble()
val minRatio = 1.0 / 2.39
val maxRatio = 2.39
val clamped = ratio.coerceIn(minRatio, maxRatio)
// Scale to integers; Rational(width, height) directly can overflow for
// large surfaces, and the clamped value may not match the raw pixels.
return Rational((clamped * 1000).toInt(), 1000)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
Quad(
android.R.drawable.ic_media_pause,
"Pause",
CONTROL_PAUSE,
REQUEST_PAUSE
)
} else {
Quad(
android.R.drawable.ic_media_play,
"Play",
CONTROL_PLAY,
REQUEST_PLAY
)
}
val intent = Intent(ACTION_MEDIA_CONTROL)
.putExtra(EXTRA_CONTROL_TYPE, controlType)
// Explicit package keeps the broadcast internal to the app.
.setPackage(activity.packageName)
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val pendingIntent = android.app.PendingIntent.getBroadcast(
activity,
requestCode,
intent,
flags
)
return RemoteAction(
Icon.createWithResource(activity, iconRes),
title,
title,
pendingIntent
)
}
private data class Quad<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D
)
/**
* Refresh the PiP window's action button so it tracks play/pause state
* while the window is open. Safe to call when not in PiP (no-op).
*/
fun updatePipActions(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (!activity.isInPictureInPictureMode) return
try {
activity.setPictureInPictureParams(buildParams(activity))
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to update PiP actions", e)
}
}
/**
* Called from MainActivity.onPictureInPictureModeChanged.
*
* Entering: hide the WebView so only the video surface shows, and register
* the receiver backing the PiP play/pause button.
* Leaving: restore the WebView and unregister.
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
hideWebView(activity)
registerReceiver(activity)
} else {
unregisterReceiver(activity)
showWebView()
// The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized.
try {
JellyTauPlayer.getInstance().fitSurfaceToScreen()
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to re-fit surface after PiP", e)
}
}
}
private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView)
if (webView == null) {
android.util.Log.w(TAG, "No WebView found to hide for PiP")
return
}
// GONE rather than INVISIBLE: the WebView is opaque, and GONE also stops
// it from consuming layout space in the shrunken window.
webView.visibility = android.view.View.GONE
hiddenWebView = webView
android.util.Log.d(TAG, "WebView hidden for PiP")
}
private fun showWebView() {
hiddenWebView?.let {
it.visibility = android.view.View.VISIBLE
android.util.Log.d(TAG, "WebView restored after PiP")
}
hiddenWebView = null
}
private fun registerReceiver(activity: Activity) {
if (receiver != null) return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
// Swap the button to reflect the new state.
updatePipActions(activity)
}
}
val filter = IntentFilter(ACTION_MEDIA_CONTROL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
activity.registerReceiver(r, filter)
}
receiver = r
android.util.Log.d(TAG, "PiP media control receiver registered")
}
private fun unregisterReceiver(activity: Activity) {
receiver?.let {
try {
activity.unregisterReceiver(it)
} catch (e: IllegalArgumentException) {
// Already unregistered - harmless.
}
}
receiver = null
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWebView(view.getChildAt(i))?.let { return it }
}
}
return null
}
}
@@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() {
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
// The scrubber is absolute; Rust owns the seek in absolute terms
// (in a background-audio handoff it rebuilds the stream at this
// StartTimeTicks). Send the absolute position as-is.
val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
@@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen
// MediaSession. During a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
// position RELATIVE to that point (starting at 0). The metadata duration,
// however, is the full absolute length — so without this base the scrubber
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
// position via setPositionOffset(); 0 for normal playback.
private var positionOffsetMs: Long = 0L
/**
* Set the base position offset (seconds) applied to lockscreen positions.
* Called by the native layer when entering/exiting a background-audio handoff.
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
*/
fun setPositionOffset(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
}
/**
* Update the MediaSession metadata and playback state, plus the notification.
*
@@ -292,8 +314,16 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Update MediaSession playback state (position made absolute via the base offset).
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
// before) enableRemoteVolume(); this keeps the session routed to the
// remote (absolute) volume slider instead of the local media stream.
if (isRemoteVolumeEnabled) {
volumeProvider?.let { session.setPlaybackToRemote(it) }
}
// Update the notification
updateNotification(title, artist, isPlaying)
@@ -314,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
@@ -326,8 +357,17 @@ class JellyTauPlaybackService : MediaSessionService() {
*
* The reported playback speed is 1.0 while playing and 0.0 while paused so
* Android does not extrapolate the position past a paused track.
*
* While remote volume control is enabled (casting), the state is forced to
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
* (absolute) volume slider for a session that is actively playing; if a
* periodic metadata/position push reports paused (e.g. before the remote
* session has actually started), reporting STATE_PAUSED here makes the
* system tear down the remote slider set up by setPlaybackToRemote() and
* fall back to the local media-stream volume.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
val playing = isPlaying || isRemoteVolumeEnabled
return PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
@@ -338,9 +378,9 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_SEEK_TO
)
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position,
if (isPlaying) 1.0f else 0.0f
if (playing) 1.0f else 0.0f
)
.build()
}
@@ -2,5 +2,4 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

+61 -16
View File
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
use crate::connectivity::ConnectivityMonitor;
use crate::jellyfin::http_client::HttpClient;
pub use session_verifier::SessionVerifier;
@@ -99,7 +99,10 @@ impl AuthManager {
}
/// Set the connectivity monitor (for marking server reachability)
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
pub fn set_connectivity_monitor(
&mut self,
monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
) {
self.connectivity_monitor = Some(monitor);
}
@@ -133,9 +136,17 @@ impl AuthManager {
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
match self
.http_client
.get_json_fast::<PublicSystemInfo>(&endpoint)
.await
{
Ok(info) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
log::info!(
"[AuthManager] Connected to server: {} ({})",
info.server_name,
info.version
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -181,7 +192,10 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(None, device_id);
// Build request manually for custom headers
let request = self.http_client.client.post(&endpoint)
let request = self
.http_client
.client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.json(&serde_json::json!({
@@ -192,19 +206,31 @@ impl AuthManager {
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| format!("Login request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
}
let auth_response: AuthenticateByNameResponse = response.json().await
let auth_response: AuthenticateByNameResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse login response: {}", e))?;
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
log::info!(
"[AuthManager] Login successful for user: {} ({})",
auth_response.user.name,
auth_response.user.id
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -243,13 +269,19 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request manually for custom headers
let request = self.http_client.client.get(&endpoint)
let request = self
.http_client
.client
.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| {
log::warn!("[AuthManager] Session verification failed: {}", e);
format!("Session verification failed: {}", e)
@@ -257,24 +289,34 @@ impl AuthManager {
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
// Mark server as unreachable for auth errors
if status.as_u16() == 401 || status.as_u16() == 403 {
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
monitor
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
.await;
}
}
return Err(format!("HTTP {}: {}", status, error_text));
}
let user_response: JellyfinUser = response.json().await
let user_response: JellyfinUser = response
.json()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))?;
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
log::info!(
"[AuthManager] Session verified successfully for: {}",
user_response.name
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -306,7 +348,10 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request
let request = self.http_client.client.post(&endpoint)
let request = self
.http_client
.client
.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
+17 -5
View File
@@ -1,8 +1,8 @@
use serde::Serialize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use super::{AuthManager, User};
@@ -65,7 +65,10 @@ impl SessionVerifier {
let session = auth_manager.get_session().await;
if let Some(session) = session {
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
log::debug!(
"[SessionVerifier] Verifying session for: {}",
session.username
);
// Verify the session
match auth_manager
@@ -113,7 +116,10 @@ impl SessionVerifier {
reason: "Session expired".to_string(),
};
if let Err(e) = app.emit("auth:needs-reauth", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
log::error!(
"[SessionVerifier] Failed to emit event: {}",
e
);
}
}
@@ -131,12 +137,18 @@ impl SessionVerifier {
message: e.clone(),
};
if let Err(e) = app.emit("auth:network-error", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
log::error!(
"[SessionVerifier] Failed to emit event: {}",
e
);
}
}
} else {
// Unknown error - log but don't invalidate
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
log::error!(
"[SessionVerifier] Unknown error during verification: {}",
e
);
}
}
}
+48 -18
View File
@@ -1,7 +1,11 @@
//! Authentication and session-lifecycle commands.
//!
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
use std::sync::Arc;
use tauri::State;
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
/// Wrapper for AuthManager to manage in Tauri state
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
log::info!("[AuthManager] Restoring session from storage...");
// Use the existing storage_get_active_session function
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
let active_session =
match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
// Store in AuthManager
auth_manager.0.set_session(Some(session.clone())).await;
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
log::info!(
"[AuthManager] Session restored for user: {} with normalized URL: {}",
session.username,
session.server_url
);
Ok(Some(session))
}
@@ -80,7 +89,10 @@ pub async fn auth_login(
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
let result = auth_manager
.0
.login(&server_url, &username, &password, &device_id)
.await?;
// Create session from auth result with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<bool, String> {
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
match auth_manager
.0
.verify_session(&server_url, &user_id, &access_token, &device_id)
.await
{
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[AuthCommands] Session verification failed: {}", e);
@@ -138,7 +154,10 @@ pub async fn auth_logout(
drop(verifier_guard);
// Call Jellyfin logout endpoint
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
auth_manager
.0
.logout(&server_url, &access_token, &device_id)
.await?;
// Clear session
auth_manager.0.set_session(None).await;
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
// Get current session to extract server_url and username
let session = auth_manager.0.get_session().await
let session = auth_manager
.0
.get_session()
.await
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
// Re-login with stored credentials
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
let result = auth_manager
.0
.login(
&session.server_url,
&session.username,
&password,
&device_id,
)
.await?;
// Update session with new token
let updated_session = Session {
+503
View File
@@ -0,0 +1,503 @@
//! Tauri commands for the offline "browse & queue" feature.
//!
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
//!
//! Two backend pieces support browsing the full server catalog while offline
//! and queueing downloads that fire on reconnect:
//!
//! - [`sync_full_catalog`] walks every library while online and persists all
//! items to the offline cache so the whole catalog is browsable (greyed out)
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
//! what `get_items` branch 3 serves offline).
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
//! heal-and-pump pattern in `player_preload_upcoming`.
use std::sync::Arc;
use log::{info, warn};
use tauri::State;
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::repository::types::GetItemsOptions;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// app_settings key holding the RFC-3339 timestamp of the last successful
/// full-catalog sync.
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
/// Item types worth caching for offline browsing: containers the library
/// landing pages render plus the playable leaves users queue for download.
const CATALOG_ITEM_TYPES: &[&str] = &[
"MusicAlbum",
"Movie",
"Series",
"Season",
"Episode",
"Audio",
"BoxSet",
];
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncResult {
/// Total items persisted to the offline cache across all libraries.
pub items_cached: usize,
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
pub libraries_failed: usize,
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncStatus {
/// RFC-3339 timestamp of the last successful sync, if any.
pub last_synced_at: Option<String>,
}
/// 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).
///
/// 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 `Recursive=true` so a single request per library returns the
/// containers and their playable children.
#[tauri::command]
#[specta::specta]
pub async fn sync_full_catalog(
repository: State<'_, RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
handle: String,
) -> Result<CatalogSyncResult, String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
info!(
"[Catalog] Full sync starting across {} libraries",
libraries.len()
);
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
let mut items_cached = 0usize;
let mut libraries_failed = 0usize;
for library in &libraries {
let opts = GetItemsOptions {
recursive: Some(true),
include_item_types: Some(include_types.clone()),
limit: Some(100_000),
..Default::default()
};
match repo.cache_items_from_server(&library.id, Some(opts)).await {
Ok(items) => {
info!(
"[Catalog] Cached {} items from library '{}'",
items.len(),
library.name
);
items_cached += items.len();
}
Err(e) => {
warn!(
"[Catalog] Failed to sync library '{}': {:?}",
library.name, e
);
libraries_failed += 1;
}
}
}
// Record the sync time so callers can skip re-syncing too eagerly.
let now = chrono::Utc::now().to_rfc3339();
let upsert = Query::with_params(
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
QueryParam::String(now),
],
);
if let Err(e) = db_service.execute(upsert).await {
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
}
info!(
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
items_cached, libraries_failed
);
Ok(CatalogSyncResult {
items_cached,
libraries_failed,
})
}
/// Report the last-synced timestamp so the UI can show a hint / decide whether
/// to trigger a fresh sync.
#[tauri::command]
#[specta::specta]
pub async fn catalog_sync_status(
db: State<'_, DatabaseWrapper>,
) -> Result<CatalogSyncStatus, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
);
let last_synced_at: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(CatalogSyncStatus { last_synced_at })
}
/// Control whether offline library queries reveal the full synced catalog
/// (greyed-out, non-downloaded media) or only downloaded/local media.
///
/// The frontend calls this from the "Show all server media" toggle: pass `true`
/// when online, or when offline with the toggle on; pass `false` 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
/// toggle.
#[tauri::command]
#[specta::specta]
pub fn set_show_server_catalog(show: bool) {
crate::repository::offline::set_include_catalog_browse(show);
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumeQueuedResult {
/// Rows whose stream URL was resolved and are now pump-eligible.
pub resolved: usize,
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
pub failed: usize,
}
/// 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)`.
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
target_dir: &str,
resolve: F,
) -> Result<ResumeQueuedResult, String>
where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let rows_query = Query::new(
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
FROM downloads
WHERE status = 'pending' AND stream_url IS NULL",
);
let rows: Vec<(i64, String, String, String)> = db_service
.query_many(rows_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.await
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(ResumeQueuedResult {
resolved: 0,
failed: 0,
});
}
info!(
"[Catalog] Resolving {} offline-queued downloads on reconnect",
rows.len()
);
let mut resolved = 0usize;
let mut failed = 0usize;
for (download_id, item_id, media_type, quality) in rows {
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
Some(url) => url,
None => {
failed += 1;
continue;
}
};
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
// concurrent resolver doesn't clobber an already-started row.
let update = Query::with_params(
"UPDATE downloads SET stream_url = ?, target_dir = ?
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir.to_string()),
QueryParam::Int64(download_id),
],
);
match db_service.execute(update).await {
Ok(n) if n > 0 => resolved += 1,
Ok(_) => {} // already resolved by someone else; not a failure
Err(e) => {
warn!(
"[Catalog] Failed to persist URL for download {}: {}",
download_id, e
);
failed += 1;
}
}
}
Ok(ResumeQueuedResult { resolved, failed })
}
/// 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.
///
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
/// 'video') via the pure `get_video_download_url` builder using the row's stored
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
/// be resolved are left pending (they retry on the next reconnect).
#[tauri::command]
#[specta::specta]
pub async fn resume_queued_downloads(
repository: State<'_, RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
handle: String,
) -> Result<ResumeQueuedResult, String> {
use crate::repository::MediaRepository;
use crate::repository::HybridRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
// The pump needs a target_dir; use the same storage root the other download
// paths use (the database's parent directory — see `storage_get_path`).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?;
let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
};
// Recover stale downloads: rows left in 'downloading' when the app was killed
// mid-transfer are orphaned — nothing ever restarts them, so they show as
// permanently "downloading". Reset them to 'pending' and clear the stale
// stream_url so they get re-resolved and restarted from scratch below.
let recover_query = Query::new(
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
bytes_downloaded = 0, started_at = NULL \
WHERE status = 'downloading'",
);
match db_service.execute(recover_query).await {
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
Ok(_) => {}
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
}
// Resolve each row's URL against the (now reachable) repository.
let repo_for_resolve = Arc::clone(&repo);
let outcome = resolve_pending_download_urls(
&db_service,
&target_dir,
move |item_id: String, media_type: String, quality: String| {
let repo = Arc::clone(&repo_for_resolve);
async move {
if media_type == "video" {
Some(
<HybridRepository as MediaRepository>::get_video_download_url(
repo.as_ref(),
&item_id,
&quality,
None,
),
)
} else {
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Err(e) => {
warn!(
"[Catalog] Failed to resolve audio URL for {}: {:?}",
item_id, e
);
None
}
}
}
}
},
)
.await
.map_err(|e| e.to_string())?;
let ResumeQueuedResult { resolved, failed } = outcome;
// Kick the pump so the newly-resolved rows actually start.
if resolved > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
}
info!(
"[Catalog] Resume complete: {} resolved, {} failed",
resolved, failed
);
Ok(ResumeQueuedResult { resolved, failed })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::db_service::RusqliteService;
use rusqlite::Connection;
use std::sync::Mutex;
fn test_db() -> Arc<RusqliteService> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
status TEXT NOT NULL,
stream_url TEXT,
target_dir TEXT,
media_type TEXT,
quality_preset TEXT
);
"#,
)
.unwrap();
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
async fn insert_download(
db: &Arc<RusqliteService>,
item_id: &str,
status: &str,
stream_url: Option<&str>,
media_type: Option<&str>,
) {
let q = Query::with_params(
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(status.to_string()),
stream_url
.map(|s| QueryParam::String(s.to_string()))
.unwrap_or(QueryParam::Null),
media_type
.map(|s| QueryParam::String(s.to_string()))
.unwrap_or(QueryParam::Null),
],
);
db.execute(q).await.unwrap();
}
async fn get_row(
db: &Arc<RusqliteService>,
item_id: &str,
) -> (String, Option<String>, Option<String>) {
let q = Query::with_params(
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
);
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.unwrap()
}
#[tokio::test]
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
let db = test_db();
// A row queued offline: pending with no URL yet.
insert_download(&db, "queued-1", "pending", None, None).await;
// An already-resolved pending row: must NOT be touched.
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
// A completed row: irrelevant.
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
let out =
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
})
.await
.unwrap();
assert_eq!(out.resolved, 1);
assert_eq!(out.failed, 0);
// The offline-queued row now has a URL + target dir and stays pending.
let (status, url, target) = get_row(&db, "queued-1").await;
assert_eq!(status, "pending");
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
assert_eq!(target.as_deref(), Some("/data/downloads"));
// The already-resolved row is unchanged (not re-resolved).
let (_s, url2, _t) = get_row(&db, "already").await;
assert_eq!(url2.as_deref(), Some("http://existing/url"));
}
#[tokio::test]
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
let db = test_db();
insert_download(&db, "bad", "pending", None, None).await;
// Resolver returns None (e.g. server lookup failed).
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
.await
.unwrap();
assert_eq!(out.resolved, 0);
assert_eq!(out.failed, 1);
// Still pending with no URL, so a later reconnect can retry it.
let (status, url, _t) = get_row(&db, "bad").await;
assert_eq!(status, "pending");
assert_eq!(url, None);
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
let out =
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}"))
})
.await
.unwrap();
assert_eq!(out.resolved, 1);
let (_s, url, _t) = get_row(&db, "vid-1").await;
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
//! Server-reachability / connectivity commands.
//!
//! TRACES: UR-043 | IR-027 | DR-055
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
use std::sync::Arc;
use tauri::State;
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
/// Wrapper for ConnectivityMonitor managed state
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
+3 -2
View File
@@ -1,11 +1,12 @@
//! Tauri commands for unit conversions and formatting
//!
//! TRACES: UR-005 | DR-009
//!
//! These commands expose conversion utilities to the frontend,
//! allowing centralized conversion logic in Rust.
use crate::utils::conversions::{
format_time, format_time_long, calculate_progress,
ticks_to_seconds, percent_to_volume,
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
};
/// Format time in seconds to MM:SS display string
+4 -1
View File
@@ -81,7 +81,10 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
/// TRACES: UR-009 | DR-011
#[tauri::command]
#[specta::specta]
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
pub async fn device_set_id(
device_id: String,
db: State<'_, DatabaseWrapper>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
+479 -76
View File
@@ -2,14 +2,15 @@
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::{Manager, State};
use log::{debug, error, info, warn};
use super::{DatabaseWrapper, SmartCacheWrapper};
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
use crate::download::{DownloadInfo, DownloadManager};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use super::{DatabaseWrapper, SmartCacheWrapper};
// Cohesive command clusters in their own submodules, re-exported so the command
// names remain at `commands::download::*` (invoke_handler unchanged).
@@ -21,6 +22,80 @@ pub use smart_cache::*;
/// Wrapper for DownloadManager to be used as Tauri state
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
/// Wrapper for the current network transport, used by the WiFi-only gate.
///
/// TRACES: UR-053 | DR-074
pub struct NetworkStateWrapper(pub NetworkStateHandle);
/// Report the device's current network transport (Android → Rust).
///
/// The frontend calls this on startup and whenever the native network callback
/// fires. Updating to an acceptable network re-pumps the download queue, so a
/// queue parked on "waiting for WiFi" drains itself without user action.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn set_network_state(
app: tauri::AppHandle,
network: NetworkStateWrapperArg,
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
) -> Result<(), String> {
let new_state = NetworkState {
network_type: network.network_type,
unmetered: network.unmetered,
};
let handle = app.state::<NetworkStateWrapper>().0.clone();
let previous = handle.get().await;
handle.set(new_state).await;
if previous != new_state {
info!(
"[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
);
}
// If the new network unblocks the gate, drain whatever was waiting.
if downloads_allowed_on_current_network(&app).await {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let active = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app.clone(), db_service, active).await;
}
Ok(())
}
/// Argument struct for [`set_network_state`].
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkStateWrapperArg {
pub network_type: NetworkType,
pub unmetered: bool,
}
/// Whether downloads are currently permitted by the WiFi-only gate.
///
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
/// rather than leaving them looking silently stuck.
///
/// TRACES: UR-053 | DR-074
#[tauri::command]
#[specta::specta]
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
Ok(downloads_allowed_on_current_network(&app).await)
}
/// Download statistics computed server-side
#[allow(dead_code)]
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -111,7 +186,13 @@ pub async fn download_item_and_start(
request: DownloadItemAndStartRequest,
) -> Result<i64, String> {
let DownloadItemAndStartRequest {
item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name,
item_id,
user_id,
stream_url,
target_dir,
item_name,
artist_name,
album_name,
} = request;
// Sanitize filename
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
@@ -132,7 +213,8 @@ pub async fn download_item_and_start(
album_name,
expected_size: None,
},
).await?;
)
.await?;
// Start the download immediately
start_download(
@@ -142,7 +224,8 @@ pub async fn download_item_and_start(
download_id,
stream_url,
target_dir,
).await?;
)
.await?;
Ok(download_id)
}
@@ -156,7 +239,15 @@ pub async fn download_item(
request: DownloadItemRequest,
) -> Result<i64, String> {
let DownloadItemRequest {
item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size,
item_id,
user_id,
file_path,
mime_type,
priority,
item_name,
artist_name,
album_name,
expected_size,
} = request;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -172,18 +263,24 @@ pub async fn download_item(
};
// Check if we have space
let can_download = cache_arc.can_download_async(&db_service, &user_id, size as u64).await;
let can_download = cache_arc
.can_download_async(&db_service, &user_id, size as u64)
.await;
if !can_download {
warn!("Storage limit reached. Attempting to free space...");
// Try to evict LRU items to make space
match cache_arc.evict_lru_async(&db_service, &user_id, size as u64).await {
match cache_arc
.evict_lru_async(&db_service, &user_id, size as u64)
.await
{
Ok(freed) if freed > 0 => {
info!("Freed {} bytes, proceeding with download", freed);
}
Ok(_) => {
let storage_limit = cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
let storage_limit =
cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
return Err(format!(
"Storage limit reached ({} bytes). Unable to free enough space.",
storage_limit
@@ -220,7 +317,10 @@ pub async fn download_item(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the download ID by unique constraint columns
// NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
@@ -291,12 +391,18 @@ pub async fn download_album(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(track_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(track_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -317,8 +423,17 @@ pub async fn download_video(
request: DownloadVideoRequest,
) -> Result<i64, String> {
let DownloadVideoRequest {
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
series_name, season_name, episode_number, season_number,
item_id,
user_id,
file_path,
mime_type,
priority,
item_name,
quality_preset,
series_name,
season_name,
episode_number,
season_number,
} = request;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -358,7 +473,10 @@ pub async fn download_video(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the download ID by unique constraint columns
let id_query = Query::with_params(
@@ -403,7 +521,13 @@ pub async fn download_series(
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
.query_many(episodes_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.await
.map_err(|e| e.to_string())?;
@@ -413,7 +537,9 @@ pub async fn download_series(
// Queue each episode with descending priority (first episodes download first)
// Priority starts high and decreases so earlier episodes finish first
let total_episodes = episodes.len() as i32;
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in episodes.into_iter().enumerate() {
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
episodes.into_iter().enumerate()
{
let priority = 1000 - idx as i32; // High priority for first episodes
// Create path like: videos/SeriesName/S01E01_Title.mp4
@@ -425,7 +551,12 @@ pub async fn download_series(
episode_num,
sanitize_filename(&episode_name)
);
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
let file_path = format!(
"{}/{}/{}",
base_path,
sanitize_filename(&series_name),
file_name
);
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
@@ -450,17 +581,29 @@ pub async fn download_series(
QueryParam::String(episode_name),
QueryParam::String(quality.clone()),
QueryParam::String(series_name.clone()),
season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
season_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
episode_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
season_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(episode_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -471,7 +614,10 @@ pub async fn download_series(
download_ids.push(download_id);
}
info!("[download_series] Queued {} episodes for series '{}'", total_episodes, series_name);
info!(
"[download_series] Queued {} episodes for series '{}'",
total_episodes, series_name
);
Ok(download_ids)
}
@@ -524,7 +670,12 @@ pub async fn download_season(
episode_num,
sanitize_filename(&episode_name)
);
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
let file_path = format!(
"{}/{}/{}",
base_path,
sanitize_filename(&series_name),
file_name
);
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
@@ -550,11 +701,17 @@ pub async fn download_season(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(episode_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -565,11 +722,15 @@ pub async fn download_season(
download_ids.push(download_id);
}
info!("[download_season] Queued {} episodes for {} - {}", download_ids.len(), series_name, season_name);
info!(
"[download_season] Queued {} episodes for {} - {}",
download_ids.len(),
series_name,
season_name
);
Ok(download_ids)
}
/// Helper to compute download statistics from a list of downloads
#[allow(dead_code)]
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
@@ -674,7 +835,10 @@ pub async fn get_downloads(
/// Pause a download
#[tauri::command]
#[specta::specta]
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn pause_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -692,7 +856,10 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
/// Resume a paused download
#[tauri::command]
#[specta::specta]
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn resume_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -738,13 +905,20 @@ pub async fn cancel_download(
vec![QueryParam::Int64(download_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
// Unregister from download manager (in case it was active)
{
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.unregister_download(download_id);
info!("Cancelled download {}. Active downloads: {}", download_id, manager.active_count());
info!(
"Cancelled download {}. Active downloads: {}",
download_id,
manager.active_count()
);
}
// Delete partial file if exists
@@ -800,7 +974,10 @@ pub async fn mark_download_failed(
let query = Query::with_params(
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
vec![QueryParam::String(error_message), QueryParam::Int64(download_id)],
vec![
QueryParam::String(error_message),
QueryParam::Int64(download_id),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
@@ -834,7 +1011,10 @@ pub async fn start_download(
})?;
if !manager.can_start_download() {
warn!("Cannot start download: maximum concurrent downloads ({}) reached", manager.max_concurrent());
warn!(
"Cannot start download: maximum concurrent downloads ({}) reached",
manager.max_concurrent()
);
debug!(" Active downloads: {}", manager.active_count());
return Err(format!(
"Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
@@ -845,12 +1025,19 @@ pub async fn start_download(
// Register this download as active
let registered = manager.register_download(download_id);
if !registered {
warn!("Failed to register download {}: already registered or limit reached", download_id);
warn!(
"Failed to register download {}: already registered or limit reached",
download_id
);
return Err("Download already in progress or limit reached".to_string());
}
info!("Download {} registered. Active downloads: {}/{}",
download_id, manager.active_count(), manager.max_concurrent());
info!(
"Download {} registered. Active downloads: {}/{}",
download_id,
manager.active_count(),
manager.max_concurrent()
);
}
// Get download info from DB
@@ -868,21 +1055,23 @@ pub async fn start_download(
);
let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.query_one(info_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.await
.map_err(|e| {
error!("Failed to query download info: {}", e);
e.to_string()
})?;
debug!(" Retrieved: item_id={}, file_path={}, file_size={:?}", item_id, file_path, file_size);
debug!(
" Retrieved: item_id={}, file_path={}, file_size={:?}",
item_id, file_path, file_size
);
// Make a HEAD request to get the file size from Content-Length header
debug!("Making HEAD request to get file size...");
let head_response = reqwest::Client::new()
.head(&stream_url)
.send()
.await;
let head_response = reqwest::Client::new().head(&stream_url).send().await;
let file_size_from_server = match head_response {
Ok(response) => {
@@ -893,7 +1082,11 @@ pub async fn start_download(
.and_then(|v| v.parse::<i64>().ok());
if let Some(size) = size {
debug!(" Got file size from server: {} bytes ({} MB)", size, size / 1024 / 1024);
debug!(
" Got file size from server: {} bytes ({} MB)",
size,
size / 1024 / 1024
);
} else {
warn!(" Server didn't provide Content-Length header");
}
@@ -929,7 +1122,10 @@ pub async fn start_download(
)
};
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
db_service
.execute(update_query)
.await
.map_err(|e| e.to_string())?;
// Emit started event
let started_event = DownloadEvent::Started {
@@ -937,7 +1133,10 @@ pub async fn start_download(
item_id: item_id.clone(),
};
debug!("Emitting download-event: {:?}", started_event);
debug!(" Serialized: {}", serde_json::to_string(&started_event).unwrap_or_default());
debug!(
" Serialized: {}",
serde_json::to_string(&started_event).unwrap_or_default()
);
match app.emit("download-event", started_event) {
Ok(_) => debug!(" Event emitted successfully"),
Err(e) => error!(" Event emit failed: {:?}", e),
@@ -998,7 +1197,10 @@ pub async fn enqueue_download(
QueryParam::Int64(download_id),
],
);
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
db_service
.execute(update_query)
.await
.map_err(|e| e.to_string())?;
// Kick the pump: it will start as many pending downloads as there are slots.
let active_downloads = {
@@ -1056,7 +1258,9 @@ pub async fn enqueue_video_downloads(
};
// Build the transcode URL (pure URL builder, no server round-trip).
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
let stream_url = repo
.as_ref()
.get_video_download_url(&item_id, &quality, None);
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
@@ -1067,7 +1271,10 @@ pub async fn enqueue_video_downloads(
],
);
if let Err(e) = db_service.execute(update_query).await {
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
warn!(
"[enqueue_video] Failed to persist URL for download {}: {}",
download_id, e
);
}
}
@@ -1081,6 +1288,37 @@ pub async fn enqueue_video_downloads(
Ok(())
}
/// Whether the current network permits downloads, given the user's WiFi-only
/// preference.
///
/// Reads `wifi_only` from the SmartCache config (the single home of the
/// setting) and checks it against the transport reported by the platform. On
/// desktop the transport defaults to unmetered ethernet, so this is always
/// true there.
///
/// TRACES: UR-053 | DR-074
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
let wifi_only = {
let smart_cache = app.state::<SmartCacheWrapper>();
let cache = match smart_cache.0.lock() {
Ok(c) => c,
Err(e) => {
error!("[pump] Failed to lock smart cache: {}", e);
// Fail open: a lock problem must not silently wedge downloads.
return true;
}
};
cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
};
if !wifi_only {
return true;
}
let network = app.state::<NetworkStateWrapper>();
network.0.allows_download(true).await
}
/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
@@ -1095,6 +1333,16 @@ pub(crate) async fn pump_download_queue(
use crate::download::events::DownloadEvent;
use tauri::Emitter;
// WiFi-only gate (UR-053): when the user has restricted downloads to
// unmetered networks and we're on cellular (or can't tell), leave every
// pending row exactly as it is. They stay 'pending' and the Android
// network callback re-pumps us as soon as an acceptable network appears.
if !downloads_allowed_on_current_network(&app).await {
info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
return;
}
let max_concurrent = {
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
@@ -1137,7 +1385,13 @@ pub(crate) async fn pump_download_queue(
let candidates: Vec<(i64, String, String, String, String)> = match db_service
.query_many(next_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.await
{
@@ -1189,7 +1443,10 @@ pub(crate) async fn pump_download_queue(
vec![QueryParam::Int64(download_id)],
);
if let Err(e) = db_service.execute(update_query).await {
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
error!(
"[pump] Failed to mark download {} downloading: {}",
download_id, e
);
if let Ok(mut a) = active_downloads.lock() {
a.remove(&download_id);
}
@@ -1228,8 +1485,8 @@ fn spawn_download_worker(
target_path: std::path::PathBuf,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use crate::download::{DownloadTask, DownloadWorker};
use tauri::Emitter;
let task = DownloadTask {
@@ -1265,16 +1522,66 @@ fn spawn_download_worker(
// Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
debug!(
" Unregistered download {}. Active downloads: {}",
download_id,
active.len()
);
}
// The pump runs downloads in the background, so the terminal status MUST
// be persisted to the DB here — the frontend event handler only writes it
// when that download happens to be loaded in its store, which is not the
// case for auto-pumped rows (or any completion while the downloads page is
// closed). `check_for_local_download` filters on status = 'completed', so a
// missed write leaves finished files unrecognized: albums never show as
// downloaded and playback never switches from the (expiring) stream to the
// local file, cutting tracks off mid-play.
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!(
"[pump] Failed to lock database after download {}: {}",
download_id, e
);
return;
}
};
Arc::new(database.service())
};
match result {
Ok(res) => {
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
info!(
"Download completed successfully: {} bytes",
res.bytes_downloaded
);
let file_path = target_path.to_string_lossy().to_string();
let update = Query::with_params(
"UPDATE downloads SET status = 'completed', progress = 1.0, \
bytes_downloaded = ?, file_size = ?, file_path = ?, \
completed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![
QueryParam::Int64(res.bytes_downloaded as i64),
QueryParam::Int64(res.bytes_downloaded as i64),
QueryParam::String(file_path.clone()),
QueryParam::Int64(download_id),
],
);
if let Err(e) = db_service.execute(update).await {
error!(
"[pump] Failed to persist completed status for download {}: {}",
download_id, e
);
}
let completed_event = DownloadEvent::Completed {
download_id,
item_id,
file_path: target_path.to_string_lossy().to_string(),
file_path,
};
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
@@ -1283,6 +1590,21 @@ fn spawn_download_worker(
}
Err(e) => {
error!("Download failed: {:?}", e);
let update = Query::with_params(
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
vec![
QueryParam::String(e.to_string()),
QueryParam::Int64(download_id),
],
);
if let Err(db_err) = db_service.execute(update).await {
error!(
"[pump] Failed to persist failed status for download {}: {}",
download_id, db_err
);
}
let failed_event = DownloadEvent::Failed {
download_id,
item_id,
@@ -1296,17 +1618,6 @@ fn spawn_download_worker(
}
// A slot just freed — start the next pending download (if any).
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
return;
}
};
Arc::new(database.service())
};
pump_download_queue(app.clone(), db_service, active_downloads).await;
});
}
@@ -1314,7 +1625,10 @@ fn spawn_download_worker(
/// Delete a completed download
#[tauri::command]
#[specta::specta]
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn delete_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1338,7 +1652,10 @@ pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
vec![QueryParam::Int64(download_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
// Delete actual file if exists
if let Some(path) = file_path {
@@ -1375,8 +1692,12 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
episode_number: row.get(20)?,
season_number: row.get(21)?,
quality_preset: row.get(22)?,
media_type: row.get::<_, Option<String>>(23)?.unwrap_or_else(|| "audio".to_string()),
download_source: row.get::<_, Option<String>>(24)?.unwrap_or_else(|| "user".to_string()),
media_type: row
.get::<_, Option<String>>(23)?
.unwrap_or_else(|| "audio".to_string()),
download_source: row
.get::<_, Option<String>>(24)?
.unwrap_or_else(|| "user".to_string()),
})
}
@@ -1462,7 +1783,10 @@ pub async fn get_download_storage_stats(
/// Delete all downloads for a user
#[tauri::command]
#[specta::specta]
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
pub async fn delete_all_downloads(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1560,7 +1884,10 @@ pub async fn delete_album_downloads(
"SELECT d.file_path FROM downloads d
JOIN items i ON d.item_id = i.id
WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
vec![QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone())],
vec![
QueryParam::String(user_id.clone()),
QueryParam::String(album_id.clone()),
],
);
let file_paths: Vec<String> = db_service
@@ -1588,6 +1915,76 @@ pub async fn delete_album_downloads(
Ok(deleted_count as i64)
}
/// Remove every completed download at or under a container item.
///
/// Works at any level of the Downloaded browse: a leaf (removes just that
/// download), an album/season/series (removes all downloaded descendants linked
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
/// on-disk files. Returns the number of downloads removed. Idempotent.
///
/// TRACES: UR-055 | DR-083
#[tauri::command]
#[specta::specta]
pub async fn delete_downloads_under(
db: State<'_, DatabaseWrapper>,
item_id: String,
user_id: String,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// The item itself, or any child linked to it by container id.
const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
AND (
d.item_id = ?
OR d.item_id IN (
SELECT c.id FROM items c
WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
)
)";
let file_query = Query::with_params(
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
vec![
QueryParam::String(user_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
],
);
let file_paths: Vec<String> = db_service
.query_many(file_query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
let delete_query = Query::with_params(
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
vec![
QueryParam::String(user_id),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::String(item_id),
],
);
let deleted_count = db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
for path in file_paths {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{}.part", path));
}
Ok(deleted_count as i64)
}
/// Download manager statistics
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct DownloadManagerStats {
@@ -1627,7 +2024,6 @@ pub async fn set_max_concurrent_downloads(
Ok(())
}
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
#[cfg(test)]
mod tests {
@@ -1796,7 +2192,10 @@ mod tests {
)
.unwrap();
assert_eq!(status, "pending", "Status should be reset to pending after UPSERT");
assert_eq!(
status, "pending",
"Status should be reset to pending after UPSERT"
);
}
#[test]
@@ -2031,7 +2430,11 @@ mod tests {
.unwrap();
let status: String = conn
.query_row("SELECT status FROM downloads WHERE id = ?1", params![id], |row| row.get(0))
.query_row(
"SELECT status FROM downloads WHERE id = ?1",
params![id],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "downloading");
+6 -1
View File
@@ -1,4 +1,6 @@
//! Pinning commands - protect an item's cached metadata from cache clearing.
//!
//! TRACES: UR-044 | DR-056
use std::sync::Arc;
use tauri::State;
@@ -45,7 +47,10 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
/// Check if an item is pinned
#[tauri::command]
#[specta::specta]
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
pub async fn is_item_pinned(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<bool, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1,7 +1,9 @@
//! Smart-cache statistics/config and album recommendation commands.
//!
//! TRACES: UR-045 | DR-057
use std::sync::Arc;
use log::info;
use std::sync::Arc;
use tauri::State;
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
+4 -2
View File
@@ -2,6 +2,7 @@
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
// DR-015, DR-017, DR-021, DR-028
pub mod auth;
pub mod catalog;
pub mod connectivity;
pub mod conversions;
pub mod device;
@@ -17,17 +18,18 @@ pub mod storage;
pub mod sync;
pub use auth::*;
pub use catalog::*;
pub use connectivity::*;
pub use conversions::*;
pub use device::*;
pub use download::*;
pub use offline::*;
pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
+35 -15
View File
@@ -1,3 +1,7 @@
//! Playback-mode transfer commands (local ↔ remote).
//!
//! TRACES: UR-010 | DR-059
use std::sync::Arc;
use tauri::State;
@@ -105,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Get session info
match client.get_session(&session_id).await {
Ok(Some(session)) => {
let position_ticks = session.play_state.as_ref()
let position_ticks = session
.play_state
.as_ref()
.and_then(|ps| ps.position_ticks)
.unwrap_or(0);
let duration_ticks = session.now_playing_item.as_ref()
let duration_ticks = session
.now_playing_item
.as_ref()
.and_then(|item| item.run_time_ticks)
.unwrap_or(0);
let is_paused = session.play_state.as_ref()
let is_paused = session
.play_state
.as_ref()
.and_then(|ps| ps.is_paused)
.unwrap_or(true);
@@ -224,17 +237,20 @@ mod tests {
fn test_playback_mode_deserialization_from_frontend() {
// Test what frontend sends for Idle mode
let idle_json = r#"{"type":"idle"}"#;
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
let mode: PlaybackMode =
serde_json::from_str(idle_json).expect("Failed to deserialize idle");
assert_eq!(mode, PlaybackMode::Idle);
// Test what frontend sends for Local mode
let local_json = r#"{"type":"local"}"#;
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
let mode: PlaybackMode =
serde_json::from_str(local_json).expect("Failed to deserialize local");
assert_eq!(mode, PlaybackMode::Local);
// Test what frontend sends for Remote mode
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
let mode: PlaybackMode =
serde_json::from_str(remote_json).expect("Failed to deserialize remote");
match mode {
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
_ => panic!("Expected Remote mode"),
@@ -247,8 +263,8 @@ mod tests {
// Test Search context (the recently fixed issue)
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
let context: PlayTracksContext = serde_json::from_str(search_json)
.expect("Failed to deserialize search context");
let context: PlayTracksContext =
serde_json::from_str(search_json).expect("Failed to deserialize search context");
match context {
PlayTracksContext::Search { search_query } => {
assert_eq!(search_query, "test query");
@@ -257,11 +273,15 @@ mod tests {
}
// Test Playlist context
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
let context: PlayTracksContext = serde_json::from_str(playlist_json)
.expect("Failed to deserialize playlist context");
let playlist_json =
r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
let context: PlayTracksContext =
serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
match context {
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
PlayTracksContext::Playlist {
playlist_id,
playlist_name,
} => {
assert_eq!(playlist_id, "pl-123");
assert_eq!(playlist_name, "My Playlist");
}
@@ -270,8 +290,8 @@ mod tests {
// Test Custom context
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
let context: PlayTracksContext = serde_json::from_str(custom_json)
.expect("Failed to deserialize custom context");
let context: PlayTracksContext =
serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
match context {
PlayTracksContext::Custom { label } => {
assert_eq!(label, Some("Custom Queue".to_string()));
+32 -7
View File
@@ -1,5 +1,7 @@
//! Tauri commands for playback reporting operations
//!
//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
//!
//! These commands provide frontend access to the Rust playback reporting system,
//! replacing the TypeScript implementation with native Rust reporting.
//!
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::jellyfin::client::JellyfinClient;
use crate::jellyfin::JellyfinConfig;
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Tauri state wrapper for PlaybackReporter
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
// Store in wrapper
*reporter_wrapper.0.lock().await = Some(reporter);
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
log::info!(
"[PlaybackReporter] Initialized successfully for user: {}",
user_id
);
Ok(())
}
@@ -205,7 +210,12 @@ mod tests {
};
// Verify enum variant can be created and pattern matched
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
if let PlaybackOperation::Start {
item_id,
position_ticks,
context,
} = operation
{
assert_eq!(item_id, "item-123");
assert_eq!(position_ticks, 15_000_000);
assert!(context.is_some());
@@ -225,7 +235,10 @@ mod tests {
context: None,
};
if let PlaybackOperation::Start { item_id, context, .. } = operation {
if let PlaybackOperation::Start {
item_id, context, ..
} = operation
{
assert_eq!(item_id, "item-789");
assert!(context.is_none());
} else {
@@ -241,7 +254,12 @@ mod tests {
is_paused: true,
};
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
if let PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
} = operation
{
assert_eq!(item_id, "item-999");
assert_eq!(position_ticks, 30_000_000);
assert!(is_paused);
@@ -272,7 +290,11 @@ mod tests {
position_ticks: 120_000_000,
};
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
if let PlaybackOperation::Stopped {
item_id,
position_ticks,
} = operation
{
assert_eq!(item_id, "item-111");
assert_eq!(position_ticks, 120_000_000);
} else {
@@ -364,7 +386,10 @@ mod tests {
};
let cloned = operation.clone();
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
if let PlaybackOperation::Progress {
item_id, is_paused, ..
} = cloned
{
assert_eq!(item_id, "item-clone");
assert!(is_paused);
} else {
File diff suppressed because it is too large Load Diff
+67 -26
View File
@@ -1,4 +1,6 @@
//! Queue manipulation commands (add / remove / move / skip).
//!
//! TRACES: UR-015 | DR-005, DR-020
use std::path::PathBuf;
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position);
info!(
"player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch track metadata via repository
info!("Fetching metadata for track {} via repository", request.track_id);
let track = repository.get_item(&request.track_id).await
info!(
"Fetching metadata for track {} via repository",
request.track_id
);
let track = repository
.get_item(&request.track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
// Check for local download first
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -188,23 +201,31 @@ pub async fn player_add_track_by_id(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -250,18 +271,25 @@ pub async fn player_add_tracks_by_ids(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(), request.position);
info!(
"player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(),
request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch metadata and build MediaItems for all tracks
let mut media_items = Vec::new();
for track_id in &request.track_ids {
info!("Fetching metadata for track {} via repository", track_id);
let track = repository.get_item(track_id).await
let track = repository
.get_item(track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
// Check for local download first
@@ -275,7 +303,9 @@ pub async fn player_add_tracks_by_ids(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -290,23 +320,31 @@ pub async fn player_add_tracks_by_ids(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -339,7 +377,10 @@ pub async fn player_add_tracks_by_ids(
drop(queue_lock);
controller.emit_queue_changed();
info!("Successfully added {} tracks to queue", request.track_ids.len());
info!(
"Successfully added {} tracks to queue",
request.track_ids.len()
);
Ok(result)
}
+80 -16
View File
@@ -1,5 +1,7 @@
//! Remote Jellyfin session control commands (casting to another device).
//!
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
//!
//! These thin command adapters forward control actions to the active Jellyfin
//! session via the player's configured `JellyfinClient`.
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
item_ids: Vec<String>,
start_index: usize,
) -> Result<(), String> {
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
log::info!(
"[RemoteSession] Playing {} items on session {} (start index: {})",
item_ids.len(),
session_id,
start_index
);
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
client.play_on_session(session_id, item_ids, start_index, None).await?;
client
.play_on_session(session_id, item_ids, start_index, None)
.await?;
log::info!("[RemoteSession] Successfully started playback on remote session");
Ok(())
} else {
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
Err(
"Jellyfin client not configured - please restart the app or log out and log back in"
.to_string(),
)
}
}
@@ -44,11 +60,19 @@ pub async fn remote_send_command(
session_id: String,
command: String,
) -> Result<(), String> {
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
log::info!(
"[RemoteSession] Sending command '{}' to session {}",
command,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
log::info!(
"[RemoteSession] Seeking to {} ticks on session {}",
position_ticks,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
session_id: String,
volume: i32,
) -> Result<(), String> {
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
log::info!(
"[RemoteSession] Setting volume to {} on session {}",
volume,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
log::info!(
"[LmsSync] Fusing zones: master={}, slaves={:?}",
master_mac,
slave_macs
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
+2
View File
@@ -1,5 +1,7 @@
//! Media session state commands.
//!
//! TRACES: UR-005 | DR-009
//!
//! Read and dismiss the current media session (the Now Playing surface backing
//! lockscreen/notification controls).
@@ -1,4 +1,6 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
use tauri::State;
+11 -4
View File
@@ -1,5 +1,7 @@
//! Sleep-timer and autoplay commands.
//!
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
//!
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
//! logic, plus persistence of autoplay settings to the database.
@@ -7,8 +9,8 @@ use std::sync::Arc;
use tauri::State;
use super::{
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
PlayerStateWrapper,
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
PlayerStatus,
};
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -127,7 +129,9 @@ pub async fn player_play_next_episode(
let media_item = create_media_item(item, Some(&db)).await?;
let controller = player.0.lock().await;
controller.play_item(media_item).map_err(|e| e.to_string())?;
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
if let Some(repo) = repo {
controller.on_video_playback_ended(id, repo).await?
} else {
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
log::warn!(
"[Autoplay] No repository available for video autoplay (itemId: {})",
id
);
AutoplayDecision::Stop
}
} else {
+33 -12
View File
@@ -6,8 +6,8 @@
use log::debug;
use tauri::State;
use crate::repository::{MediaRepository, types::*};
use super::repository::RepositoryManagerWrapper;
use crate::repository::{types::*, MediaRepository};
/// Create a new playlist
#[tauri::command]
@@ -21,7 +21,8 @@ pub async fn playlist_create(
debug!("[PLAYLIST] create called: name={}", name);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
let ids = item_ids.unwrap_or_default();
repo.as_ref().create_playlist(&name, &ids)
repo.as_ref()
.create_playlist(&name, &ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -36,7 +37,8 @@ pub async fn playlist_delete(
) -> Result<(), String> {
debug!("[PLAYLIST] delete called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().delete_playlist(&playlist_id)
repo.as_ref()
.delete_playlist(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -50,9 +52,13 @@ pub async fn playlist_rename(
playlist_id: String,
name: String,
) -> Result<(), String> {
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
debug!(
"[PLAYLIST] rename called: id={}, name={}",
playlist_id, name
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().rename_playlist(&playlist_id, &name)
repo.as_ref()
.rename_playlist(&playlist_id, &name)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -67,7 +73,8 @@ pub async fn playlist_get_items(
) -> Result<Vec<PlaylistEntry>, String> {
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playlist_items(&playlist_id)
repo.as_ref()
.get_playlist_items(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -81,9 +88,14 @@ pub async fn playlist_add_items(
playlist_id: String,
item_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
debug!(
"[PLAYLIST] add_items called: id={}, count={}",
playlist_id,
item_ids.len()
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
repo.as_ref()
.add_to_playlist(&playlist_id, &item_ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -97,9 +109,14 @@ pub async fn playlist_remove_items(
playlist_id: String,
entry_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
debug!(
"[PLAYLIST] remove_items called: id={}, count={}",
playlist_id,
entry_ids.len()
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
repo.as_ref()
.remove_from_playlist(&playlist_id, &entry_ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -114,9 +131,13 @@ pub async fn playlist_move_item(
item_id: String,
new_index: u32,
) -> Result<(), String> {
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
debug!(
"[PLAYLIST] move_item called: playlist={}, item={}, index={}",
playlist_id, item_id, new_index
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
repo.as_ref()
.move_playlist_item(&playlist_id, &item_id, new_index)
.await
.map_err(|e| format!("{:?}", e))
}
+145 -36
View File
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
};
/// Repository handle manager
pub struct RepositoryManager {
@@ -81,8 +83,13 @@ pub async fn repository_create(
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
.with_connectivity(connectivity_reporter);
let online = OnlineRepository::new(
Arc::new(http_client),
server_url,
user_id.clone(),
access_token,
)
.with_connectivity(connectivity_reporter);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching libraries...");
repo.as_ref().get_libraries()
.await
.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
repo.as_ref().get_libraries().await.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
}
/// Get items in a container (library, folder, album, etc.)
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items(&parent_id, options)
repo.as_ref()
.get_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -183,7 +189,58 @@ pub async fn repository_get_item(
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
repo.as_ref()
.get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Downloaded-only browse: libraries that contain downloaded content.
///
/// Backs the Downloads "Downloaded" surface. Never merges server results and is
/// authoritative — an empty list means nothing is downloaded.
///
/// TRACES: UR-055 | DR-082
#[tauri::command]
#[specta::specta]
pub async fn repository_get_downloaded_libraries(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<Library>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.get_downloaded_libraries()
.await
.map_err(|e| format!("{:?}", e))
}
/// Downloaded-only browse: items under a container that are on the device.
///
/// TRACES: UR-055 | DR-082, DR-083
#[tauri::command]
#[specta::specta]
pub async fn repository_get_downloaded_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.get_downloaded_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// On-disk usage of downloaded content (device total, per-item/container bytes).
///
/// TRACES: UR-056 | DR-085
#[tauri::command]
#[specta::specta]
pub async fn repository_get_download_disk_usage(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<DownloadDiskUsage, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.get_download_disk_usage()
.await
.map_err(|e| format!("{:?}", e))
}
@@ -200,7 +257,8 @@ pub async fn repository_jray_actors_at(
t: f64,
) -> Result<Vec<crate::repository::JRayActor>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_jray_actors(&item_id, t)
repo.as_ref()
.get_jray_actors(&item_id, t)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -215,7 +273,8 @@ pub async fn repository_get_latest_items(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_latest_items(&parent_id, limit)
repo.as_ref()
.get_latest_items(&parent_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -235,7 +294,8 @@ pub async fn repository_get_resume_items(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
repo.as_ref()
.get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
@@ -253,7 +313,8 @@ pub async fn repository_get_next_up_episodes(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
repo.as_ref()
.get_next_up_episodes(series_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -267,7 +328,8 @@ pub async fn repository_get_recently_played_audio(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_recently_played_audio(limit)
repo.as_ref()
.get_recently_played_audio(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -281,7 +343,8 @@ pub async fn repository_get_resume_movies(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_resume_movies(limit)
repo.as_ref()
.get_resume_movies(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -296,7 +359,8 @@ pub async fn repository_get_rediscover_albums(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
repo.as_ref()
.get_rediscover_albums(parent_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -310,7 +374,8 @@ pub async fn repository_get_genres(
parent_id: Option<String>,
) -> Result<Vec<Genre>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_genres(parent_id.as_deref())
repo.as_ref()
.get_genres(parent_id.as_deref())
.await
.map_err(|e| format!("{:?}", e))
}
@@ -363,8 +428,7 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let event = SearchUpdateEvent {
request_id,
result: merged,
@@ -376,7 +440,10 @@ pub async fn repository_search(
Err(e) => {
// Server failed — the cache results are already on screen, so
// just log. (Offline / unreachable server falls here.)
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
warn!(
"[Search] Server search failed, keeping cache results: {:?}",
e
);
}
}
});
@@ -393,7 +460,8 @@ pub async fn repository_get_playback_info(
item_id: String,
) -> Result<PlaybackInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playback_info(&item_id)
repo.as_ref()
.get_playback_info(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -421,6 +489,31 @@ pub async fn repository_get_video_stream_url(
.map_err(|e| format!("{:?}", e))
}
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// TRACES: UR-040 | JA-032 | UT-061
#[tauri::command]
#[specta::specta]
pub async fn repository_get_audio_only_stream_url_for_video(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_only_stream_url_for_video(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get audio stream URL for a track
#[tauri::command]
#[specta::specta]
@@ -486,10 +579,12 @@ pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_start(&item_id, position_ticks)
repo.as_ref()
.report_playback_start(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -501,10 +596,12 @@ pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
) -> Result<(), String> {
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_progress(&item_id, position_ticks)
repo.as_ref()
.report_playback_progress(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -516,10 +613,13 @@ pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
) -> Result<(), String> {
// Milliseconds across the boundary; the Jellyfin API wants ticks.
let position_ticks = position_ms * 10_000;
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
repo.as_ref()
.report_playback_stopped(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -551,7 +651,9 @@ pub fn repository_get_subtitle_url(
format: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
Ok(repo
.as_ref()
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
}
/// Get video download URL with quality preset
@@ -566,7 +668,9 @@ pub fn repository_get_video_download_url(
media_source_id: Option<String>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
Ok(repo
.as_ref()
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
}
/// Mark an item as favorite
@@ -578,7 +682,8 @@ pub async fn repository_mark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
repo.as_ref()
.mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -592,7 +697,8 @@ pub async fn repository_unmark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
repo.as_ref()
.unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -606,7 +712,8 @@ pub async fn repository_get_person(
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
repo.as_ref()
.get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -621,7 +728,8 @@ pub async fn repository_get_items_by_person(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items_by_person(&person_id, options)
repo.as_ref()
.get_items_by_person(&person_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -636,7 +744,8 @@ pub async fn repository_get_similar_items(
limit: Option<usize>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_similar_items(&item_id, limit)
repo.as_ref()
.get_similar_items(&item_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
+2 -2
View File
@@ -1,9 +1,9 @@
//! TRACES: UR-010 | JA-021 | DR-037
use crate::jellyfin::client::SessionInfo;
use crate::session_poller::{PollingHint, SessionPollerManager};
use std::sync::Arc;
use tauri::State;
use crate::session_poller::{PollingHint, SessionPollerManager};
use crate::jellyfin::client::SessionInfo;
/// Tauri state wrapper for SessionPollerManager
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
+157 -59
View File
@@ -1,4 +1,6 @@
//! Tauri commands for database/storage operations
//!
//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
use std::sync::{Arc, Mutex};
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
use tauri::State;
use crate::credentials::CredentialStore;
use crate::storage::Database;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::storage::Database;
use crate::thumbnail::ThumbnailCache;
use super::SmartCacheWrapper;
@@ -86,7 +88,8 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
let db_path = database.path();
// Return the parent directory instead of the database file path
let storage_dir = db_path.parent()
let storage_dir = db_path
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?;
Ok(storage_dir.to_string_lossy().to_string())
@@ -160,13 +163,16 @@ pub async fn storage_save_server(
/// Get all saved servers
#[tauri::command]
#[specta::specta]
pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> {
pub async fn storage_get_servers(
db: State<'_, DatabaseWrapper>,
) -> Result<Vec<ServerInfo>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
let query =
Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
let servers = db_service
.query_many(query, |row| {
@@ -221,7 +227,10 @@ pub async fn storage_delete_server(
vec![QueryParam::String(server_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -237,7 +246,10 @@ pub async fn storage_save_user(
username: String,
access_token: Option<String>,
) -> Result<bool, String> {
info!("storage_save_user called: id={}, server_id={}, username={}", id, server_id, username);
info!(
"storage_save_user called: id={}, server_id={}, username={}",
id, server_id, username
);
let (db_service, db_path) = {
let database = db.0.lock().map_err(|e| {
@@ -277,7 +289,10 @@ pub async fn storage_save_user(
"SELECT COUNT(*) FROM users WHERE id = ?",
vec![QueryParam::String(id.clone())],
);
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
let verify_count: i32 = db_service
.query_one(verify_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("VERIFY: {} users with id={} after insert", verify_count, id);
debug!("Database path: {:?}", db_path);
@@ -355,14 +370,20 @@ pub async fn storage_set_active_user(
// Deactivate ALL users globally (since we only connect to one server at a time)
let deactivate_query = Query::new("UPDATE users SET is_active = 0");
db_service.execute(deactivate_query).await.map_err(|e| e.to_string())?;
db_service
.execute(deactivate_query)
.await
.map_err(|e| e.to_string())?;
// Activate the specified user and update last_login_at
let activate_query = Query::with_params(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::String(user_id.clone())],
);
let rows_affected = db_service.execute(activate_query).await.map_err(|e| e.to_string())?;
let rows_affected = db_service
.execute(activate_query)
.await
.map_err(|e| e.to_string())?;
debug!("storage_set_active_user: {} rows affected", rows_affected);
@@ -372,7 +393,10 @@ pub async fn storage_set_active_user(
// Verify the user is now active
let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
let verify_count: i32 = db_service
.query_one(verify_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("VERIFY: {} active users after set_active", verify_count);
debug!("Database path: {:?}", db_path);
@@ -434,12 +458,21 @@ pub async fn storage_get_active_session(
// Debug: count total users and active users
let total_query = Query::new("SELECT COUNT(*) FROM users");
let total_users: i32 = db_service.query_one(total_query, |row| row.get(0)).await.unwrap_or(-1);
let total_users: i32 = db_service
.query_one(total_query, |row| row.get(0))
.await
.unwrap_or(-1);
let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
let active_users: i32 = db_service.query_one(active_query, |row| row.get(0)).await.unwrap_or(-1);
let active_users: i32 = db_service
.query_one(active_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("Database state: {} total users, {} active users", total_users, active_users);
debug!(
"Database state: {} total users, {} active users",
total_users, active_users
);
debug!("Database path: {:?}", db_path);
// Find active user with their server info, ordered by most recently logged in
@@ -449,18 +482,21 @@ pub async fn storage_get_active_session(
JOIN servers s ON u.server_id = s.id
WHERE u.is_active = 1
ORDER BY u.last_login_at DESC
LIMIT 1"
LIMIT 1",
);
let result = db_service.query_optional(session_query, |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
}).await.map_err(|e| e.to_string())?;
let result = db_service
.query_optional(session_query, |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
})
.await
.map_err(|e| e.to_string())?;
match result {
Some((user_id, username, server_id, server_url, server_name)) => {
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
server_name,
access_token,
}))
},
}
Err(e) => {
// Token not found or error - session is invalid
warn!("Failed to get token from secure storage: {:?}", e);
@@ -547,7 +583,9 @@ pub async fn storage_delete_user(
#[serde(rename_all = "camelCase")]
pub struct PlaybackProgress {
pub item_id: String,
pub position_ticks: i64,
/// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
/// converted here so the frontend never sees ticks.
pub position_ms: i64,
pub is_played: bool,
pub is_favorite: bool,
pub play_count: i32,
@@ -561,8 +599,11 @@ pub async fn storage_update_playback_progress(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
) -> Result<(), String> {
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
// stays on this side of the boundary. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -613,12 +654,14 @@ pub async fn storage_update_playback_context(
db: State<'_, DatabaseWrapper>,
user_id: String,
item_id: String,
position_ticks: i64,
position_ms: i64,
context_type: Option<String>,
context_id: Option<String>,
) -> Result<(), String> {
use crate::storage::db_service::{Query, QueryParam};
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
let position_ticks = position_ms * 10_000;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -638,8 +681,12 @@ pub async fn storage_update_playback_context(
QueryParam::String(user_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int64(position_ticks),
context_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
context_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
context_type
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
context_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
],
);
@@ -721,14 +768,23 @@ pub async fn storage_mark_played(
});
if !tracks.is_empty() {
info!("Auto-queueing {} tracks from album for download", tracks.len());
info!(
"Auto-queueing {} tracks from album for download",
tracks.len()
);
// Queue each track with high priority (50) and mark as auto-downloaded
for (track_id, track_name, artist_name, album_name) in tracks {
// Generate a sanitized file path (simplified version)
let sanitized_name = track_name
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
@@ -808,9 +864,10 @@ pub async fn storage_get_playback_progress(
db_service
.query_optional(query, |row| {
let position_ticks: i64 = row.get(1)?;
Ok(PlaybackProgress {
item_id: row.get(0)?,
position_ticks: row.get(1)?,
position_ms: position_ticks / 10_000,
is_played: row.get::<_, i32>(2)? != 0,
is_favorite: row.get::<_, i32>(3)? != 0,
play_count: row.get(4)?,
@@ -1123,7 +1180,10 @@ pub async fn storage_search_items(
limit_clause
);
let query_obj = Query::with_params(sql, vec![QueryParam::String(server_id), QueryParam::String(fts_query)]);
let query_obj = Query::with_params(
sql,
vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
);
let items = db_service
.query_many(query_obj, row_to_cached_item)
@@ -1181,7 +1241,8 @@ pub async fn storage_save_item(
};
// Generate sort_name from name (remove leading "The ", "A ", etc.)
let sort_name = item.name
let sort_name = item
.name
.strip_prefix("The ")
.or_else(|| item.name.strip_prefix("A "))
.or_else(|| item.name.strip_prefix("An "))
@@ -1202,28 +1263,66 @@ pub async fn storage_save_item(
vec![
QueryParam::String(item.id),
QueryParam::String(server_id),
item.library_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.parent_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.library_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.parent_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
QueryParam::String(item.name),
QueryParam::String(sort_name),
QueryParam::String(item.item_type),
item.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.genres.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.runtime_ticks.map(QueryParam::Int64).unwrap_or(QueryParam::Null),
item.production_year.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.community_rating.map(QueryParam::Float).unwrap_or(QueryParam::Null),
item.official_rating.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_artist.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.artists.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.series_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.season_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.parent_index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.overview
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.genres
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.runtime_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
item.production_year
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
item.community_rating
.map(QueryParam::Float)
.unwrap_or(QueryParam::Null),
item.official_rating
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.primary_image_tag
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_artist
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.artists
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.index_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
item.series_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.series_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.season_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.season_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.parent_index_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
],
);
@@ -1256,7 +1355,6 @@ pub async fn storage_get_pending_sync_count(
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1405,7 +1503,7 @@ mod tests {
fn test_playback_progress_serialization() {
let progress = PlaybackProgress {
item_id: "item-123".to_string(),
position_ticks: 150_000_000,
position_ms: 150_000_000,
is_played: true,
is_favorite: false,
play_count: 3,
@@ -1422,7 +1520,7 @@ mod tests {
fn test_playback_progress_played_status() {
let progress = PlaybackProgress {
item_id: "item-456".to_string(),
position_ticks: 0,
position_ms: 0,
is_played: true,
is_favorite: true,
play_count: 1,
@@ -1440,7 +1538,7 @@ mod tests {
fn test_playback_progress_not_played() {
let progress = PlaybackProgress {
item_id: "item-789".to_string(),
position_ticks: 30_000_000,
position_ms: 30_000_000,
is_played: false,
is_favorite: false,
play_count: 0,
@@ -1510,7 +1608,7 @@ mod tests {
fn test_playback_progress_camel_case() {
let progress = PlaybackProgress {
item_id: "i1".to_string(),
position_ticks: 100,
position_ms: 100,
is_played: true,
is_favorite: false,
play_count: 1,
@@ -1519,7 +1617,7 @@ mod tests {
let json = serde_json::to_string(&progress).unwrap();
// Verify camelCase serialization
assert!(json.contains("itemId"));
assert!(json.contains("positionTicks"));
assert!(json.contains("positionMs"));
assert!(json.contains("isPlayed"));
assert!(json.contains("isFavorite"));
assert!(json.contains("playCount"));
+43 -24
View File
@@ -1,13 +1,14 @@
//! Person/cast metadata cache commands.
//!
//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Cached person info returned to frontend
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
QueryParam::String(person.id),
QueryParam::String(person.server_id),
QueryParam::String(person.name),
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
person
.overview
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.primary_image_tag
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.premiere_date
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.end_date
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
],
);
@@ -117,25 +130,32 @@ pub async fn storage_save_item_people(
let associations_clone = associations.clone();
// Use transaction for batch insert
db_service.transaction(move |tx| {
for assoc in &associations_clone {
let query = Query::with_params(
"INSERT OR REPLACE INTO item_people (
db_service
.transaction(move |tx| {
for assoc in &associations_clone {
let query = Query::with_params(
"INSERT OR REPLACE INTO item_people (
item_id, person_id, server_id, person_type, role, sort_order, synced_at
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(assoc.item_id.clone()),
QueryParam::String(assoc.person_id.clone()),
QueryParam::String(assoc.server_id.clone()),
QueryParam::String(assoc.person_type.clone()),
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::Int(assoc.sort_order),
],
);
tx.execute(query)?;
}
Ok(())
}).await.map_err(|e| e.to_string())?;
vec![
QueryParam::String(assoc.item_id.clone()),
QueryParam::String(assoc.person_id.clone()),
QueryParam::String(assoc.server_id.clone()),
QueryParam::String(assoc.person_type.clone()),
assoc
.role
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
QueryParam::Int(assoc.sort_order),
],
);
tx.execute(query)?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
Ok(people)
}
@@ -1,13 +1,14 @@
//! Per-series preferred audio track commands.
//!
//! TRACES: UR-021 | DR-024
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Audio track preference for a series
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+44 -26
View File
@@ -1,9 +1,11 @@
//! Thumbnail cache and image-URL commands.
//!
//! TRACES: UR-007 | JA-028 | DR-016
use std::sync::{Arc, OnceLock};
use tokio::sync::Semaphore;
use serde::Deserialize;
use std::sync::{Arc, OnceLock};
use tauri::State;
use tokio::sync::Semaphore;
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
@@ -11,7 +13,6 @@ use crate::repository::types::{ImageOptions, ImageType};
use crate::repository::MediaRepository;
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
/// Get cached thumbnail path, returns None if not cached
/// Also updates last_accessed timestamp for LRU tracking
#[tauri::command]
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
Arc::new(database.service())
};
let result = thumbnail_cache.0
let result = thumbnail_cache
.0
.get_cached_path(db_service, &item_id, &image_type, &tag)
.await
.map(|p| p.to_string_lossy().to_string());
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
Arc::new(database.service())
};
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
let path = thumbnail_cache
.0
.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
.await?;
Ok(path.to_string_lossy().to_string())
}
@@ -179,7 +184,7 @@ pub async fn image_get_url(
repository_handle: String,
request: GetImageRequest,
) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use std::fs;
let tag = request.tag.as_deref().unwrap_or("default");
@@ -191,14 +196,18 @@ pub async fn image_get_url(
};
// Check cache first
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
db_service.clone(),
&request.item_id,
&request.image_type,
tag,
).await {
let image_data = fs::read(&cached_path)
.map_err(|e| format!("Failed to read cached image: {}", e))?;
if let Some(cached_path) = thumbnail_cache
.0
.get_cached_path(
db_service.clone(),
&request.item_id,
&request.image_type,
tag,
)
.await
{
let image_data =
fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
@@ -206,10 +215,14 @@ pub async fn image_get_url(
// Not cached — fetch from server and cache.
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
let _permit = image_semaphore().acquire().await
let _permit = image_semaphore()
.acquire()
.await
.map_err(|_| "Image download semaphore closed".to_string())?;
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
let image_type_enum = match request.image_type.as_str() {
@@ -229,18 +242,23 @@ pub async fn image_get_url(
};
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
let image_data = repository.download_bytes(&server_url).await
let image_data = repository
.download_bytes(&server_url)
.await
.map_err(|e| format!("Failed to download image: {}", e))?;
let cached_path = thumbnail_cache.0.save_thumbnail(
db_service,
&request.item_id,
&request.image_type,
tag,
&image_data,
request.max_width.map(|w| w as i32),
request.max_height.map(|h| h as i32),
).await?;
let cached_path = thumbnail_cache
.0
.save_thumbnail(
db_service,
&request.item_id,
&request.image_type,
tag,
&image_data,
request.max_width.map(|w| w as i32),
request.max_height.map(|h| h as i32),
)
.await?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
+6 -9
View File
@@ -53,7 +53,10 @@ pub async fn sync_queue_mutation(
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
let id = db_service
.last_insert_rowid()
.await
.map_err(|e| e.to_string())?;
Ok(id)
}
@@ -110,10 +113,7 @@ pub async fn sync_get_pending(
/// Mark a sync operation as in progress
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_processing(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -131,10 +131,7 @@ pub async fn sync_mark_processing(
/// Mark a sync operation as completed
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_completed(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
+35 -10
View File
@@ -1,9 +1,9 @@
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
@@ -170,9 +170,15 @@ impl ConnectivityReporter {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
log::error!(
"[ConnectivityMonitor] Failed to emit connectivity change event: {}",
e
);
} else {
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
log::info!(
"[ConnectivityMonitor] Emitted connectivity change: {}",
is_reachable
);
}
}
}
@@ -181,7 +187,10 @@ impl ConnectivityReporter {
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
log::error!(
"[ConnectivityMonitor] Failed to emit reconnection event: {}",
e
);
} else {
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
}
@@ -233,7 +242,14 @@ impl ConnectivityMonitor {
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
log::info!(
"[ConnectivityMonitor] New server is {}",
if is_reachable {
"REACHABLE"
} else {
"UNREACHABLE"
}
);
}
/// Get current connectivity status
@@ -298,11 +314,16 @@ impl ConnectivityMonitor {
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)");
log::info!(
"[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"
);
// Perform an immediate check so startup reflects reality quickly.
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
log::info!(
"[ConnectivityMonitor] Initial connectivity check: {}",
if is_reachable { "ONLINE" } else { "OFFLINE" }
);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
@@ -452,7 +473,9 @@ mod tests {
let reporter = test_reporter();
// Force offline.
reporter.apply_probe_result(false, Some("down".to_string())).await;
reporter
.apply_probe_result(false, Some("down".to_string()))
.await;
assert!(!is_reachable(&reporter).await);
// A single success brings us straight back online.
@@ -490,7 +513,9 @@ mod tests {
assert!(!is_reachable(&reporter).await);
// Should not panic or change state.
reporter.report_network_failure(Some("still down".to_string())).await;
reporter
.report_network_failure(Some("still down".to_string()))
.await;
assert!(!is_reachable(&reporter).await);
}
}
+101 -47
View File
@@ -105,13 +105,21 @@ impl CredentialStore {
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
pub fn save_token(
&self,
user_id: &str,
token: &str,
) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Saving token for user {} to encrypted file at {:?}",
user_id,
self.credentials_path
);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
@@ -124,7 +132,11 @@ impl CredentialStore {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Getting token for user {} from encrypted file at {:?}",
user_id,
self.credentials_path
);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
@@ -197,7 +209,7 @@ impl CredentialStore {
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
@@ -232,8 +244,8 @@ impl CredentialStore {
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
use std::process::{Command, Stdio};
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
@@ -248,20 +260,27 @@ impl CredentialStore {
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
stdin.write_all(token.as_bytes()).map_err(|e| {
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
})?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
let status = child.wait().map_err(|e| {
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
})?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool failed with status: {}",
status
)))
}
}
@@ -290,7 +309,11 @@ impl CredentialStore {
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
log::debug!(
"Looking up token with service={}, username={}",
SERVICE_NAME,
key
);
let output = Command::new("secret-tool")
.arg("lookup")
@@ -299,18 +322,29 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
log::debug!(
"secret-tool lookup succeeded, token length: {}",
output.stdout.len()
);
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.map_err(|e| {
CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
})?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
log::warn!(
"secret-tool lookup failed with status: {} stderr: {}",
output.status,
stderr
);
Err(CredentialError::NotFound)
}
}
@@ -348,13 +382,18 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool clear failed with status: {}",
status
)))
}
}
@@ -398,10 +437,7 @@ impl CredentialStore {
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
@@ -410,7 +446,8 @@ impl CredentialStore {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
|| line.starts_with("ro.product.model=")
{
hasher.update(line.as_bytes());
}
}
@@ -439,8 +476,8 @@ impl CredentialStore {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
let encrypted_data = fs::read_to_string(&self.credentials_path)
.map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
@@ -456,19 +493,21 @@ impl CredentialStore {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let json =
serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
getrandom::getrandom(&mut nonce_bytes)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
@@ -488,14 +527,16 @@ impl CredentialStore {
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
return Err(CredentialError::Encryption(
"Invalid encrypted data".to_string(),
));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
@@ -686,32 +727,39 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let token_jstring = env.new_string(token).map_err(|e| {
CredentialError::Keyring(format!("Failed to create token string: {}", e))
})?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
&[
JValue::Object(&key_jstring.into()),
JValue::Object(&token_jstring.into()),
],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
Err(CredentialError::Keyring(
"saveToken returned false".to_string(),
))
}
}
@@ -725,8 +773,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -767,8 +815,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -784,19 +832,25 @@ mod android_keystore {
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
Err(CredentialError::Keyring(
"deleteToken returned false".to_string(),
))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
pub use android_keystore::{
initialize_secure_storage, test_keystore_available as android_test_keystore_available,
};
#[cfg(test)]
mod tests {
+180
View File
@@ -0,0 +1,180 @@
//! Jellyfin → domain translation.
//!
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
//! second provider later means a sibling `from_<provider>.rs`; the domain types
//! and every consumer stay untouched.
//!
//! Spec: docs/specs/frontend-domain-model.md
use super::media::{MediaKind, StreamKind};
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
/// Total and panic-free.
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
match stream_type {
"Audio" => StreamKind::Audio,
"Video" => StreamKind::Video,
"Subtitle" => StreamKind::Subtitle,
_ => StreamKind::Other,
}
}
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
/// The frontend must never see ticks — this is where they die.
const TICKS_PER_MILLISECOND: i64 = 10_000;
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
///
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
pub fn ticks_to_ms(ticks: i64) -> i64 {
ticks / TICKS_PER_MILLISECOND
}
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
///
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
///
/// The recognised set is every `item_type` the frontend audit found in use
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
/// subtypes Jellyfin returns in `People[].Type`.
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
match item_type {
// Music
"Audio" | "MusicVideo" => MediaKind::Track,
"MusicAlbum" => MediaKind::Album,
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
"Playlist" => MediaKind::Playlist,
// Video
"Movie" => MediaKind::Movie,
"Series" => MediaKind::Series,
"Season" => MediaKind::Season,
"Episode" => MediaKind::Episode,
// A bare video leaf with no richer classification.
"Video" => MediaKind::Movie,
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
// people (Actor/Director/Writer/Composer/…) in People[].Type.
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
MediaKind::Person
}
// A live TV channel: playable, but a non-seekable live stream.
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
// A bare channel is a container the user drills into.
"Channel" => MediaKind::Channel,
// Containers
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
// ChannelFolderItem is a container when it is a folder, else a playable
// channel leaf (distinct kind so the UI can route it to playback).
"ChannelFolderItem" => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::ChannelItem
}
}
// Unknown → safe sink. Never panics.
_ => {
if is_folder {
MediaKind::Folder
} else {
MediaKind::Other
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticks_convert_to_milliseconds() {
// 1 second = 10,000,000 ticks = 1000 ms
assert_eq!(ticks_to_ms(10_000_000), 1000);
// 90.5 s
assert_eq!(ticks_to_ms(905_000_000), 90_500);
assert_eq!(ticks_to_ms(0), 0);
// Sub-millisecond truncates toward zero, not panics.
assert_eq!(ticks_to_ms(9_999), 0);
}
#[test]
fn music_types_map() {
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
}
#[test]
fn video_types_map() {
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
}
#[test]
fn person_and_role_types_map_to_person() {
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
}
}
#[test]
fn channel_and_container_types_map() {
assert_eq!(
kind_from_jellyfin("TvChannel", false),
MediaKind::LiveChannel
);
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
assert_eq!(
kind_from_jellyfin("CollectionFolder", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
}
#[test]
fn channel_folder_item_disambiguates_on_is_folder() {
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", true),
MediaKind::Folder
);
assert_eq!(
kind_from_jellyfin("ChannelFolderItem", false),
MediaKind::ChannelItem
);
}
#[test]
fn stream_kinds_map() {
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
assert_eq!(
stream_kind_from_jellyfin("EmbeddedImage"),
StreamKind::Other
);
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
}
#[test]
fn unknown_type_never_panics_and_falls_back() {
// The whole point: garbage in, safe kind out, no panic.
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
assert_eq!(
kind_from_jellyfin("SomeFutureType", true),
MediaKind::Folder
);
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Canonical, provider-neutral media domain model.
//!
//! This is the *single source of truth* for what a media item is across the
//! whole app. Rust (repositories, player, downloads) uses these types directly;
//! the frontend consumes the tauri-specta-generated projection in
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
//! language, so the model cannot drift.
//!
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
//! place provider terms touch the domain type.
//!
//! Spec: docs/specs/frontend-domain-model.md
use serde::{Deserialize, Serialize};
/// The kind of a media item — provider-neutral classification.
///
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
/// typo or an unhandled kind is a compile error on the frontend, not a silent
/// runtime miss across ~127 comparison sites.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum MediaKind {
// Music
Track,
Album,
Artist,
Playlist,
// Video
Movie,
Series,
Season,
Episode,
// Cast/crew
Person,
// Containers / live TV
/// A channel *container* the user drills into (Jellyfin `Channel`).
Channel,
Folder,
/// A live TV channel — playable, but a live stream with no seekable
/// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
LiveChannel,
/// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
/// not itself a folder) — e.g. a plugin-channel VOD item that has no
/// dedicated item type but carries its own media streams. Playable and
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
/// and from `Other` so the UI can route it to playback.
ChannelItem,
/// A kind we do not model explicitly. Reached only for provider item types
/// that map to nothing meaningful; consumers treat it like an opaque
/// container. The mapping must be *total* — it never panics — so this is the
/// safe sink for unknown strings. Also the `Default`, so a defaulted
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
#[default]
Other,
}
/// The kind of a media stream within an item (audio track, video track,
/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StreamKind {
Audio,
Video,
Subtitle,
/// Any stream kind we do not model explicitly (e.g. embedded image, data).
#[default]
Other,
}
impl MediaKind {
/// True for kinds that are containers/collections rather than playable leaves.
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
// Consumed by later migration phases (drill-vs-play routing); kept now so the
// domain surface is complete alongside the type it describes.
#[allow(dead_code)]
pub fn is_container(self) -> bool {
matches!(
self,
MediaKind::Album
| MediaKind::Artist
| MediaKind::Series
| MediaKind::Season
| MediaKind::Playlist
| MediaKind::Channel
| MediaKind::Folder
)
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Canonical, provider-neutral domain model — the single source of truth for
//! the app's core data shapes, shared with the frontend via generated bindings.
//!
//! Spec: docs/specs/frontend-domain-model.md
pub mod from_jellyfin;
pub mod media;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
+29 -4
View File
@@ -36,7 +36,7 @@ impl Default for CacheConfig {
album_affinity_enabled: true,
album_affinity_threshold: 3,
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
wifi_only: false, // Allow preloading on any connection by default
wifi_only: false, // Allow preloading on any connection by default
}
}
}
@@ -64,11 +64,20 @@ impl SmartCache {
}
}
/// Check if should pre-cache queue items
/// Check if should pre-cache queue items.
///
/// Note this deliberately does NOT consult `wifi_only`. It used to return
/// `queue_precache_enabled && !wifi_only`, which disabled precaching
/// outright whenever the user enabled WiFi-only — regardless of the network
/// actually in use. The network check now lives in the download queue pump
/// (`downloads_allowed_on_current_network`), which is the single gate for
/// all download traffic, so this only answers "is precaching enabled?".
///
/// TRACES: UR-053 | DR-074
pub fn should_precache_queue(&self) -> bool {
self.config
.lock()
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
.map(|cfg| cfg.queue_precache_enabled)
.unwrap_or(false)
}
@@ -225,7 +234,10 @@ impl SmartCache {
"DELETE FROM downloads WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
freed += size as u64;
}
@@ -279,6 +291,19 @@ mod tests {
assert!(cache.should_precache_queue());
}
#[test]
fn test_wifi_only_does_not_disable_precaching() {
// wifi_only must not short-circuit precaching: the network gate lives in
// the download pump, which checks the *actual* transport. Enabling
// WiFi-only while on WiFi should still precache.
let mut config = CacheConfig::default();
config.queue_precache_enabled = true;
config.wifi_only = true;
let cache = SmartCache::new(config);
assert!(cache.should_precache_queue());
}
#[tokio::test]
async fn test_storage_limit_check() {
use crate::storage::db_service::RusqliteService;
+24 -19
View File
@@ -8,16 +8,10 @@ use serde::{Deserialize, Serialize};
pub enum DownloadEvent {
/// Download has been queued
#[serde(rename_all = "camelCase")]
Queued {
download_id: i64,
item_id: String,
},
Queued { download_id: i64, item_id: String },
/// Download has started
#[serde(rename_all = "camelCase")]
Started {
download_id: i64,
item_id: String,
},
Started { download_id: i64, item_id: String },
/// Download progress update
#[serde(rename_all = "camelCase")]
Progress {
@@ -43,16 +37,15 @@ pub enum DownloadEvent {
},
/// Download paused
#[serde(rename_all = "camelCase")]
Paused {
download_id: i64,
item_id: String,
},
Paused { download_id: i64, item_id: String },
/// Download cancelled
#[serde(rename_all = "camelCase")]
Cancelled {
download_id: i64,
item_id: String,
},
Cancelled { download_id: i64, item_id: String },
/// The queue is holding: WiFi-only is enabled and the current network is
/// metered/cellular. Pending rows stay pending and resume on network change.
///
/// TRACES: UR-053 | DR-074
WaitingForNetwork,
}
#[cfg(test)]
@@ -98,9 +91,21 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"completed\""));
// Verify camelCase field names
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
assert!(
json.contains("\"downloadId\":42"),
"Expected downloadId (camelCase), got: {}",
json
);
assert!(
json.contains("\"itemId\":\"song456\""),
"Expected itemId (camelCase), got: {}",
json
);
assert!(
json.contains("\"filePath\":"),
"Expected filePath (camelCase), got: {}",
json
);
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
+2 -1
View File
@@ -8,11 +8,12 @@
pub mod cache;
pub mod events;
pub mod network;
pub mod worker;
use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub use worker::DownloadWorker;
+196
View File
@@ -0,0 +1,196 @@
//! Network transport classification for the WiFi-only download gate.
//!
//! This answers "what kind of connection are we on?", which is orthogonal to
//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
//! pump consults this before starting pending rows when the user has enabled
//! WiFi-only downloads.
//!
//! On Android the real transport is read from `NetworkCapabilities` in
//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
//! no metered-connection concept worth enforcing, so we report `Ethernet`,
//! which is always acceptable — gating desktop downloads would be a regression.
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
/// Kind of network transport currently active.
///
/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
/// in sync (the serde rename below is what the frontend sends).
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NetworkType {
/// No active network.
None,
/// WiFi (may still be metered — check `unmetered`).
Wifi,
/// Wired ethernet, typical on Android TV and desktop.
Ethernet,
/// Mobile data — never acceptable when wifi-only is enabled.
Cellular,
/// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
Other,
/// Could not determine the transport.
Unknown,
}
/// Current network transport plus whether it is metered.
///
/// TRACES: UR-053 | DR-074
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkState {
pub network_type: NetworkType,
/// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
pub unmetered: bool,
}
impl Default for NetworkState {
fn default() -> Self {
// Desktop default: wired and unmetered, so the gate never blocks there.
// Android overwrites this as soon as the frontend reports the real state.
Self {
network_type: NetworkType::Ethernet,
unmetered: true,
}
}
}
impl NetworkState {
/// Whether downloads may run right now given the wifi-only preference.
///
/// Ethernet counts as acceptable — it is unmetered in practice and is what
/// Android TV devices use. Cellular never does. `None`/`Unknown` fail
/// closed: if we cannot tell what we are on, we do not spend the user's
/// mobile data to find out.
///
/// TRACES: UR-053 | DR-074
pub fn allows_download(&self, wifi_only: bool) -> bool {
if !wifi_only {
return true;
}
match self.network_type {
NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
// Require unmetered so metered WiFi hotspots (backed by the very
// cellular data this setting protects) are excluded too.
NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
}
}
}
/// Shared, mutable view of the current network transport.
///
/// Cheap to clone; the frontend updates it via `set_network_state` whenever
/// Android reports a network change.
#[derive(Clone, Default)]
pub struct NetworkStateHandle {
state: Arc<RwLock<NetworkState>>,
}
impl NetworkStateHandle {
pub fn new() -> Self {
Self {
state: Arc::new(RwLock::new(NetworkState::default())),
}
}
pub async fn get(&self) -> NetworkState {
*self.state.read().await
}
pub async fn set(&self, new_state: NetworkState) {
*self.state.write().await = new_state;
}
/// Whether downloads may run right now given the wifi-only preference.
pub async fn allows_download(&self, wifi_only: bool) -> bool {
self.state.read().await.allows_download(wifi_only)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
NetworkState {
network_type,
unmetered,
}
}
#[test]
fn wifi_only_off_allows_every_transport() {
for t in [
NetworkType::None,
NetworkType::Wifi,
NetworkType::Ethernet,
NetworkType::Cellular,
NetworkType::Other,
NetworkType::Unknown,
] {
assert!(
state(t, false).allows_download(false),
"{t:?} should be allowed when wifi_only is off"
);
}
}
#[test]
fn cellular_is_blocked_when_wifi_only() {
// Even if somehow flagged unmetered, cellular is never acceptable.
assert!(!state(NetworkType::Cellular, true).allows_download(true));
assert!(!state(NetworkType::Cellular, false).allows_download(true));
}
#[test]
fn unmetered_wifi_and_ethernet_are_allowed() {
assert!(state(NetworkType::Wifi, true).allows_download(true));
assert!(state(NetworkType::Ethernet, true).allows_download(true));
}
#[test]
fn metered_wifi_is_blocked() {
// A phone hotspot reports as WiFi but is metered — blocking it is the
// whole point of checking NOT_METERED rather than the transport alone.
assert!(!state(NetworkType::Wifi, false).allows_download(true));
}
#[test]
fn unknown_and_none_fail_closed() {
assert!(!state(NetworkType::Unknown, true).allows_download(true));
assert!(!state(NetworkType::None, true).allows_download(true));
}
#[test]
fn desktop_default_is_never_gated() {
assert!(NetworkState::default().allows_download(true));
}
#[tokio::test]
async fn handle_roundtrips_state() {
let handle = NetworkStateHandle::new();
assert!(handle.allows_download(true).await);
handle.set(state(NetworkType::Cellular, false)).await;
assert!(!handle.allows_download(true).await);
assert!(handle.allows_download(false).await);
assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
}
#[test]
fn network_type_serializes_lowercase() {
// Must match the string constants in NetworkTypeMonitor.kt.
assert_eq!(
serde_json::to_string(&NetworkType::Wifi).unwrap(),
"\"wifi\""
);
assert_eq!(
serde_json::to_string(&NetworkType::Cellular).unwrap(),
"\"cellular\""
);
}
}
+26 -13
View File
@@ -60,7 +60,11 @@ impl DownloadWorker {
}
/// Attempt a single download
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
async fn try_download<F>(
&self,
task: &DownloadTask,
on_progress: &F,
) -> Result<DownloadResult, DownloadError>
where
F: Fn(u64, Option<u64>) + Send + Sync,
{
@@ -74,10 +78,7 @@ impl DownloadWorker {
// Check for partial download
let temp_path = task.target_path.with_extension("part");
let existing_bytes = if temp_path.exists() {
fs::metadata(&temp_path)
.await
.map(|m| m.len())
.unwrap_or(0)
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
} else {
0
};
@@ -105,14 +106,17 @@ impl DownloadWorker {
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
.map(|len| {
if existing_bytes > 0 {
len + existing_bytes
} else {
len
}
});
// Open file for appending
let mut file = if existing_bytes > 0 {
fs::OpenOptions::new()
.append(true)
.open(&temp_path)
.await
fs::OpenOptions::new().append(true).open(&temp_path).await
} else {
fs::File::create(&temp_path).await
}
@@ -206,9 +210,18 @@ mod tests {
#[test]
fn test_exponential_backoff() {
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
assert_eq!(
DownloadWorker::exponential_backoff(1),
Duration::from_secs(5)
);
assert_eq!(
DownloadWorker::exponential_backoff(2),
Duration::from_secs(15)
);
assert_eq!(
DownloadWorker::exponential_backoff(3),
Duration::from_secs(45)
);
}
#[test]
+132 -54
View File
@@ -72,7 +72,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] GET {}", endpoint);
let response = self.http_client
let response = self
.http_client
.get(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -83,13 +84,24 @@ impl JellyfinClient {
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
log::debug!(
"[JellyfinClient] Response status for {}: {}",
endpoint,
status
);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
// Get the response text first so we can log it
@@ -100,9 +112,15 @@ impl JellyfinClient {
// Log the raw response for sessions endpoint to help debug
if endpoint.contains("/Sessions") {
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
debug!(
"[JellyfinClient] Raw response for {}: {}",
endpoint,
if response_text.len() > 500 {
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
format!(
"{}... (truncated, {} bytes total)",
&response_text[..500],
response_text.len()
)
} else {
response_text.clone()
}
@@ -112,7 +130,8 @@ impl JellyfinClient {
// Parse the response text as JSON
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
log::error!("[JellyfinClient] Failed to parse response: {}", e);
log::error!("[JellyfinClient] Response was: {}",
log::error!(
"[JellyfinClient] Response was: {}",
if response_text.len() > 200 {
format!("{}...", &response_text[..200])
} else {
@@ -132,7 +151,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
let response: reqwest::Response = self.http_client
let response: reqwest::Response = self
.http_client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.get_auth_header())
@@ -145,13 +165,24 @@ impl JellyfinClient {
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
log::debug!(
"[JellyfinClient] Response status for {}: {}",
endpoint,
status
);
if !status.is_success() {
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text: String = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
@@ -193,7 +224,7 @@ impl JellyfinClient {
}
/// Report playback progress to Jellyfin
#[allow(dead_code)] // Will be used when playback_reporting is integrated
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub async fn report_playback_progress(
&self,
item_id: String,
@@ -220,9 +251,17 @@ impl JellyfinClient {
start_position_ticks: Option<i64>,
) -> Result<(), String> {
log::info!("[JellyfinClient] Playing on session: {}", session_id);
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id, item_ids.len(), start_index);
log::info!(
"[JellyfinClient] Item IDs: {:?}, Start index: {}",
item_ids,
start_index
);
debug!(
"[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id,
item_ids.len(),
start_index
);
// Build URL with query parameters (Jellyfin expects PascalCase query params)
let mut url = format!(
@@ -244,10 +283,15 @@ impl JellyfinClient {
log::info!("[JellyfinClient] POST {}", url);
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
debug!(
"[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
session_id,
item_ids.len()
);
debug!("[JellyfinClient] Sending HTTP POST request...");
let response = self.http_client
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -263,10 +307,21 @@ impl JellyfinClient {
debug!("[JellyfinClient] Response status: {}", status);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {}", error_text);
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
error!(
"[JellyfinClient] API error {}: {}",
status.as_u16(),
error_text
);
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::info!("[JellyfinClient] Successfully sent play command to remote session");
@@ -280,7 +335,11 @@ impl JellyfinClient {
session_id: String,
command: &str,
) -> Result<(), String> {
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
self.post(
&format!("/Sessions/{}/Playing/{}", session_id, command),
&serde_json::json!({}),
)
.await
}
/// Seek on a remote session
@@ -298,7 +357,8 @@ impl JellyfinClient {
self.config.server_url, session_id, position_ticks
);
let response = self.http_client
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -307,11 +367,22 @@ impl JellyfinClient {
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
log::info!(
"[JellyfinClient] Seek to {} ticks on session {}",
position_ticks,
session_id
);
Ok(())
}
@@ -332,46 +403,42 @@ impl JellyfinClient {
payload["Arguments"] = args;
}
log::info!("[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
command_name, session_id, serde_json::to_string(&payload).unwrap_or_default());
log::info!(
"[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
command_name,
session_id,
serde_json::to_string(&payload).unwrap_or_default()
);
self.post(
&format!("/Sessions/{}/Command", session_id),
&payload
).await
self.post(&format!("/Sessions/{}/Command", session_id), &payload)
.await
}
/// Set volume on a remote session
pub async fn session_set_volume(
&self,
session_id: String,
volume: i32,
) -> Result<(), String> {
pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
self.send_general_command(
&session_id,
"SetVolume",
Some(serde_json::json!({ "Volume": volume.to_string() })),
).await
)
.await
}
/// Toggle mute on a remote session
pub async fn session_toggle_mute(
&self,
session_id: String,
) -> Result<(), String> {
pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
self.send_general_command(
&session_id,
"ToggleMute",
None,
).await
self.send_general_command(&session_id, "ToggleMute", None)
.await
}
/// Get all active sessions
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
info!(
"[JellyfinClient] Fetched {} sessions from API",
sessions.len()
);
for session in &sessions {
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
session.id, session.device_name, session.client, session.supports_remote_control);
@@ -382,7 +449,9 @@ impl JellyfinClient {
/// Get a specific session by ID
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
Ok(sessions
.into_iter()
.find(|s| s.id.as_deref() == Some(session_id)))
}
// --- JellyLMS multi-room sync groups -----------------------------------
@@ -415,12 +484,14 @@ impl JellyfinClient {
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
.await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
.await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
@@ -429,7 +500,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
let response = self
.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -438,8 +510,15 @@ impl JellyfinClient {
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
Ok(())
}
@@ -570,7 +649,6 @@ pub struct PlayState {
pub shuffle_mode: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+39 -28
View File
@@ -40,7 +40,7 @@ pub enum ErrorKind {
/// Enhanced HTTP client with retry logic and error classification
#[derive(Clone)]
pub struct HttpClient {
pub(crate) client: Client, // Make accessible within crate for custom requests
pub(crate) client: Client, // Make accessible within crate for custom requests
config: HttpConfig,
}
@@ -131,18 +131,15 @@ impl HttpClient {
/// Check if a request should be retried based on the error
pub fn should_retry(error: &reqwest::Error) -> bool {
match Self::classify_error(error) {
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Authentication => false, // Don't retry 401/403
ErrorKind::Client => false, // Don't retry other 4xx errors
ErrorKind::Client => false, // Don't retry other 4xx errors
}
}
/// Make a request with automatic retry on network errors
pub async fn request_with_retry(
&self,
request: Request,
) -> Result<Response, reqwest::Error> {
pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
let max_retries = self.config.max_retries;
let mut last_error: Option<reqwest::Error> = None;
@@ -192,44 +189,58 @@ impl HttpClient {
Err(last_error.unwrap())
}
/// Make a GET request with retry
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
let request = self.client.get(url).build()?;
self.request_with_retry(request).await
}
/// Make a GET request and deserialize JSON with a short timeout and no retries.
///
/// Intended for the initial "connect to server" probe on the login screen:
/// a wrong/unreachable URL must fail fast instead of burning through the
/// default 30s-per-attempt timeout and exponential backoff retries.
pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
// Short timeout so an unreachable host fails quickly.
const FAST_TIMEOUT: Duration = Duration::from_secs(10);
/// Make a GET request and deserialize JSON response with retry
pub async fn get_json_with_retry<T: DeserializeOwned>(
&self,
url: &str,
) -> Result<T, String> {
let response = self.get_with_retry(url).await
let request = self
.client
.get(url)
.timeout(FAST_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// No retry: connection failures on a wrong URL won't succeed on retry,
// they'd only multiply the wait the user sees before an error.
let response = self
.client
.execute(request)
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("HTTP {}: {}", status, error_text));
}
response.json::<T>().await
response
.json::<T>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))
}
/// Quick ping to check if a server is reachable (no retry)
pub async fn ping(&self, url: &str) -> bool {
let request = self.client.get(url)
let request = self
.client
.get(url)
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
.build();
match request {
Ok(req) => {
match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
Ok(req) => match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
},
Err(_) => false,
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub struct PlaybackStoppedRequest {
/// Request body for reporting playback progress
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)] // Will be used when playback_reporting is integrated
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub struct PlaybackProgressRequest {
pub item_id: String,
pub position_ticks: i64,
+355 -118
View File
@@ -2,6 +2,7 @@ mod auth;
mod commands;
mod connectivity;
mod credentials;
mod domain;
mod download;
mod jellyfin;
mod playback_mode;
@@ -14,119 +15,291 @@ mod storage;
mod thumbnail;
pub mod utils;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use log::{error, info};
#[cfg(target_os = "android")]
use log::warn;
use log::{error, info};
use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use tokio::sync::Mutex as TokioMutex;
use commands::{
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
player_toggle_shuffle,
// Sleep timer and autoplay commands
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
player_get_autoplay_settings, player_set_autoplay_settings,
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
// HTML5 video state-report commands
player_report_state, player_report_position, player_report_media_loaded,
// Queue manipulation commands
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
player_remove_from_queue, player_move_in_queue, player_skip_to,
// Preload commands
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
// Jellyfin reporting commands
player_configure_jellyfin, player_disable_jellyfin,
// Session management commands
player_get_session, player_dismiss_session,
// Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status,
// Playback reporting commands
playback_reporter_init, playback_reporter_destroy,
playback_report_start, playback_report_progress, playback_report_stopped,
playback_mark_played, PlaybackReporterWrapper,
// Auth commands
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
auth_stop_verification, auth_reauthenticate,
// Device commands
device_get_id, device_set_id,
// Connectivity commands
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
connectivity_start_monitoring, connectivity_stop_monitoring,
connectivity_mark_reachable, connectivity_mark_unreachable,
// Storage commands
storage_delete_server, storage_delete_user, storage_get_access_token,
storage_get_active_session, storage_get_active_user, storage_get_path,
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
storage_update_playback_context,
// Offline cache commands
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
storage_save_library, storage_save_item, storage_get_pending_sync_count,
// Sync queue commands
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
// Thumbnail cache and image commands
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
// People cache commands
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
// Series audio preferences
storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
repository_get_subtitle_url, repository_get_video_download_url,
// Playlist commands
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
playlist_add_items, playlist_remove_items, playlist_move_item,
// Conversion commands
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
calc_progress, convert_percent_to_volume,
AuthManagerWrapper, SessionVerifierWrapper,
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
use auth::AuthManager;
use commands::{
auth_connect_to_server,
auth_get_session,
// Auth commands
auth_initialize,
auth_login,
auth_logout,
auth_reauthenticate,
auth_set_session,
auth_start_verification,
auth_stop_verification,
auth_verify_session,
calc_progress,
cancel_download,
catalog_sync_status,
clear_stale_downloads,
// Connectivity commands
connectivity_check_server,
connectivity_get_status,
connectivity_mark_reachable,
connectivity_mark_unreachable,
connectivity_set_server_url,
connectivity_start_monitoring,
connectivity_stop_monitoring,
convert_percent_to_volume,
convert_ticks_to_seconds,
delete_album_downloads,
delete_all_downloads,
delete_download,
delete_downloads_under,
// Device commands
device_get_id,
device_set_id,
download_album,
download_item,
download_item_and_start,
download_season,
download_series,
download_video,
enqueue_download,
enqueue_video_downloads,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
get_album_affinity_status,
get_album_recommendations,
get_download_manager_stats,
get_download_storage_stats,
get_downloads,
get_downloads_allowed,
get_smart_cache_config,
get_smart_cache_stats,
image_get_url,
is_item_pinned,
lms_create_sync_group,
lms_dissolve_sync_group,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_unsync_player,
mark_download_completed,
mark_download_failed,
offline_get_items,
offline_is_available,
offline_search,
pause_download,
pin_item,
playback_mark_played,
// Playback mode commands
playback_mode_get_current,
playback_mode_get_remote_status,
playback_mode_is_transferring,
playback_mode_set,
playback_mode_set_transferring,
playback_mode_transfer_to_local,
playback_mode_transfer_to_remote,
playback_report_progress,
playback_report_start,
playback_report_stopped,
playback_reporter_destroy,
// Playback reporting commands
playback_reporter_init,
// Queue manipulation commands
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_cancel_autoplay_countdown,
player_cancel_sleep_timer,
// Jellyfin reporting commands
player_configure_jellyfin,
player_cycle_repeat,
player_disable_jellyfin,
player_dismiss_session,
player_enter_background_audio,
player_exit_background_audio,
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_queue,
// Session management commands
player_get_session,
player_get_sleep_timer,
player_get_status,
player_get_video_settings,
player_move_in_queue,
player_next,
player_on_playback_ended,
player_pause,
player_play,
player_play_album_track,
player_play_item,
player_play_next_episode,
player_play_queue,
player_play_tracks,
// Preload commands
player_preload_upcoming,
player_previous,
player_remove_from_queue,
player_report_media_loaded,
player_report_position,
// HTML5 video state-report commands
player_report_state,
player_seek,
player_seek_video,
player_set_audio_settings,
player_set_audio_track,
player_set_autoplay_settings,
player_set_cache_config,
// Sleep timer and autoplay commands
player_set_sleep_timer,
player_set_subtitle_track,
player_set_video_settings,
player_set_volume,
player_skip_to,
player_stop,
player_switch_audio_track,
player_toggle,
player_toggle_mute,
player_toggle_shuffle,
playlist_add_items,
// Playlist commands
playlist_create,
playlist_delete,
playlist_get_items,
playlist_move_item,
playlist_remove_items,
playlist_rename,
// Remote session control commands
remote_play_on_session,
remote_send_command,
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// Repository commands
repository_create,
repository_destroy,
repository_get_audio_only_stream_url_for_video,
repository_get_audio_stream_url,
repository_get_channels,
repository_get_download_disk_usage,
repository_get_downloaded_items,
repository_get_downloaded_libraries,
repository_get_genres,
repository_get_image_url,
repository_get_item,
repository_get_items,
repository_get_items_by_person,
repository_get_latest_items,
repository_get_libraries,
repository_get_live_tv_channels,
repository_get_next_up_episodes,
repository_get_person,
repository_get_playback_info,
repository_get_recently_played_audio,
repository_get_rediscover_albums,
repository_get_resume_items,
repository_get_resume_movies,
repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
repository_get_video_stream_url,
repository_jray_actors_at,
repository_mark_favorite,
repository_open_live_stream,
repository_report_playback_progress,
repository_report_playback_start,
repository_report_playback_stopped,
repository_search,
repository_unmark_favorite,
resume_download,
resume_queued_downloads,
sessions_poll_now,
// Session polling commands
sessions_set_polling_hint,
set_max_concurrent_downloads,
set_network_state,
set_show_server_catalog,
start_download,
// Storage commands
storage_delete_server,
storage_delete_user,
storage_get_access_token,
storage_get_active_session,
storage_get_active_user,
storage_get_item,
storage_get_item_people,
storage_get_items,
// Offline cache commands
storage_get_libraries,
storage_get_path,
storage_get_pending_sync_count,
storage_get_person,
storage_get_playback_progress,
storage_get_security_status,
storage_get_series_audio_preference,
storage_get_servers,
storage_get_size,
storage_get_users,
storage_init,
storage_mark_played,
storage_mark_synced,
storage_save_item,
storage_save_item_people,
storage_save_library,
// People cache commands
storage_save_person,
// Series audio preferences
storage_save_series_audio_preference,
storage_save_server,
storage_save_user,
storage_search_items,
storage_set_active_user,
storage_toggle_favorite,
storage_update_playback_context,
storage_update_playback_progress,
sync_cleanup_completed,
sync_clear_user,
sync_full_catalog,
sync_get_pending,
sync_get_pending_count,
sync_mark_completed,
sync_mark_failed,
sync_mark_processing,
// Sync queue commands
sync_queue_mutation,
thumbnail_clear_cache,
thumbnail_delete_item,
// Thumbnail cache and image commands
thumbnail_get_cached,
thumbnail_get_stats,
thumbnail_save,
thumbnail_set_limit,
unpin_item,
update_smart_cache_config,
AuthManagerWrapper,
ConnectivityMonitorWrapper,
CredentialStoreWrapper,
DatabaseWrapper,
DownloadManagerWrapper,
MediaSessionManagerWrapper,
PlaybackModeManagerWrapper,
PlaybackReporterWrapper,
PlayerStateWrapper,
RepositoryManagerWrapper,
SessionPollerWrapper,
SessionVerifierWrapper,
SmartCacheWrapper,
ThumbnailCacheWrapper,
VideoSettingsWrapper,
};
use connectivity::ConnectivityMonitor;
use credentials::CredentialStore;
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
@@ -137,7 +310,7 @@ use player::NullBackend;
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
#[cfg(target_os = "android")]
use credentials::initialize_secure_storage;
@@ -146,7 +319,9 @@ use credentials::initialize_secure_storage;
use player::ExoPlayerBackend;
#[cfg(target_os = "android")]
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
use player::{
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
///
@@ -209,7 +384,11 @@ impl MediaSessionHandler {
"play" => client.send_session_command(session_id, "Unpause").await,
"pause" => client.send_session_command(session_id, "Pause").await,
"next" => client.send_session_command(session_id, "NextTrack").await,
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
"previous" => {
client
.send_session_command(session_id, "PreviousTrack")
.await
}
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(seconds) => {
let ticks = (seconds * 10_000_000.0) as i64;
@@ -296,7 +475,10 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
log::info!("[RemoteVolume] Spawning async task to send volume command...");
tauri::async_runtime::spawn(async move {
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
match playback_mode.send_remote_volume_command(&command_str, volume).await {
match playback_mode
.send_remote_volume_command(&command_str, volume)
.await
{
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
}
@@ -354,9 +536,16 @@ fn create_player_backend(
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
let context_obj =
unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
match ExoPlayerBackend::new(
&mut env,
&context_obj,
_event_emitter.clone(),
playback_reporter.clone(),
position_throttler.clone(),
) {
Ok(backend) => {
info!("Successfully initialized ExoPlayer backend for Android");
return Box::new(backend);
@@ -369,13 +558,21 @@ fn create_player_backend(
}
}
Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("attach JNI thread failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("create JavaVM failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
@@ -439,6 +636,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
player_enter_background_audio,
player_exit_background_audio,
player_play_queue,
player_play_album_track,
player_play_tracks,
@@ -578,6 +777,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
delete_download,
delete_all_downloads,
delete_album_downloads,
delete_downloads_under,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
@@ -585,11 +785,18 @@ fn specta_builder() -> Builder<tauri::Wry> {
start_download,
enqueue_download,
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
set_show_server_catalog,
resume_queued_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
get_smart_cache_stats,
update_smart_cache_config,
get_smart_cache_config,
// WiFi-only download gate (UR-053)
set_network_state,
get_downloads_allowed,
get_album_recommendations,
get_album_affinity_status,
// Pinning commands
@@ -639,6 +846,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_libraries,
repository_get_items,
repository_get_item,
repository_get_downloaded_libraries,
repository_get_downloaded_items,
repository_get_download_disk_usage,
repository_jray_actors_at,
repository_get_latest_items,
repository_get_resume_items,
@@ -651,6 +861,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
repository_get_audio_only_stream_url_for_video,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
@@ -717,7 +928,12 @@ fn enable_linux_hardware_video_decoding() {
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
@@ -920,6 +1136,9 @@ pub fn run() {
player_arc.clone(),
);
let playback_mode_arc = Arc::new(playback_mode_manager);
// Broadcast mode changes so the frontend's mirror store reconciles to
// this authoritative one (prevents remote/local control desync).
playback_mode_arc.set_event_emitter(event_emitter.clone());
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
app.manage(playback_mode_wrapper);
@@ -930,9 +1149,11 @@ pub fn run() {
playback_mode_arc.clone(),
);
session_poller.set_event_emitter(event_emitter.clone());
session_poller.start();
// Note: start() is deferred until after the connectivity monitor is
// created below, so the poller can report reachability from its first
// poll (it drives offline detection + recovery while the user is idle).
let session_poller_arc = Arc::new(session_poller);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
app.manage(session_poller_wrapper);
// On Android, set up the MediaSession (lockscreen) handler and the
@@ -959,6 +1180,9 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Background-audio handoff base offset (UR-040).
app.manage(commands::player::BackgroundAudioOffset::default());
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -986,6 +1210,13 @@ pub fn run() {
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
app.manage(download_manager_wrapper);
// Current network transport, for the WiFi-only download gate (UR-053).
// Defaults to unmetered ethernet so desktop is never gated; Android
// overwrites it via set_network_state as soon as the UI starts.
app.manage(commands::download::NetworkStateWrapper(
download::network::NetworkStateHandle::new(),
));
// Initialize connectivity monitor
info!("[INIT] Initializing connectivity monitor...");
let http_config = HttpConfig::default();
@@ -994,6 +1225,12 @@ pub fn run() {
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
connectivity_monitor.set_app_handle(app.handle().clone());
// Wire the connectivity reporter into the session poller so its
// continuous background polls drive reachability (offline detection
// + recovery) even when the user isn't browsing, then start it.
session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
session_poller_arc.start();
// Wrap in Arc for sharing with AuthManager
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
@@ -1039,7 +1276,6 @@ pub fn run() {
.expect("error while running tauri application");
}
#[cfg(test)]
mod specta_bindings {
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
@@ -1047,9 +1283,10 @@ mod specta_bindings {
fn export_typescript_bindings() {
super::specta_builder()
.export(
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
specta_typescript::Typescript::default()
.bigint(specta_typescript::BigIntExportBehavior::Number),
"../src/lib/api/bindings.ts",
)
.expect("failed to export typescript bindings");
}
}
}
+274 -56
View File
@@ -9,7 +9,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration};
use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -48,6 +48,9 @@ pub struct PlaybackModeManager {
player_controller: Arc<TokioMutex<PlayerController>>,
current_mode: Arc<RwLock<PlaybackMode>>,
is_transferring: Arc<AtomicBool>,
/// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
}
impl PlaybackModeManager {
@@ -61,19 +64,76 @@ impl PlaybackModeManager {
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
}
}
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
/// during setup; safe to leave unset (tests do), in which case mode changes
/// simply aren't broadcast.
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
/// Set playback mode (internal use).
///
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
/// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the
/// emitter call.
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe();
*current = mode;
let changed = {
let mut current = self.current_mode.write_safe();
let changed = *current != mode;
*current = mode.clone();
changed
};
if !changed {
return;
}
let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None),
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
};
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
mode: mode_str,
session_id,
});
}
}
/// Start the Android playback service and hand it remote-volume control.
///
/// Must run on EVERY transition into remote mode, because it is what starts
/// the foreground service. Without a running service there is no media
/// notification (the lockscreen card is missing) AND system volume buttons
/// aren't intercepted for the remote session (remote volume control dead).
/// Both symptoms share this one cause, so this must not be skipped on any
/// remote-entry path (notably the empty-queue early return in
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
#[allow(unused_variables)]
fn enable_remote_control(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
}
/// Check if currently transferring
@@ -97,8 +157,16 @@ impl PlaybackModeManager {
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
pub async fn send_remote_volume_command(
&self,
command: &str,
volume: i32,
) -> Result<(), String> {
log::info!(
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
command,
volume
);
// Get the current session ID
let session_id = match self.get_mode() {
@@ -109,18 +177,18 @@ impl PlaybackModeManager {
}
};
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
log::info!(
"[PlaybackMode] Current mode is Remote, session_id={}",
session_id
);
// Get Jellyfin client
let client = {
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
log::info!("[PlaybackMode] Jellyfin client lock acquired");
@@ -139,7 +207,12 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] About to call client.session_set_volume...");
// Send the volume command
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
log::info!(
"[PlaybackMode] Sending {} command to session {} (volume: {})",
command,
session_id,
volume
);
let result = client.session_set_volume(session_id, volume).await;
match &result {
@@ -152,8 +225,11 @@ impl PlaybackModeManager {
/// Extract Jellyfin item IDs from queue items
/// Returns (item_ids, adjusted_current_index)
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
fn extract_jellyfin_ids(
&self,
items: &[crate::player::MediaItem],
original_index: usize,
) -> Result<(Vec<String>, usize), String> {
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
let mut jellyfin_item_count = 0;
@@ -179,7 +255,9 @@ impl PlaybackModeManager {
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
original_index
);
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
return Err(
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
);
}
};
@@ -212,7 +290,9 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
let result = self
.transfer_to_remote_inner(&session_id, position_override)
.await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -226,7 +306,10 @@ impl PlaybackModeManager {
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
debug!(
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
session_id
);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
@@ -250,13 +333,26 @@ impl PlaybackModeManager {
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
log::info!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
debug!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
// Log each item's jellyfin_id for debugging
for (i, item) in items.iter().enumerate() {
let jf_id = item.jellyfin_id().unwrap_or("NONE");
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
log::debug!(
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
i,
item.id,
jf_id
);
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
@@ -297,6 +393,10 @@ impl PlaybackModeManager {
self.set_mode(PlaybackMode::Remote {
session_id: session_id.to_string(),
});
// Start the service + remote-volume control here too — otherwise this
// early return leaves remote mode with no media notification and no
// volume interception (lockscreen card missing + remote volume dead).
self.enable_remote_control();
return Ok(());
}
@@ -311,14 +411,11 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
let client = {
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
match client_opt.as_ref() {
Some(c) => {
@@ -344,7 +441,9 @@ impl PlaybackModeManager {
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
if let Some(ticks) =
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
{
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
@@ -352,7 +451,11 @@ impl PlaybackModeManager {
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(now_id) = session
.now_playing_item
.as_ref()
.and_then(|i| i.id.as_deref())
{
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
@@ -369,8 +472,13 @@ impl PlaybackModeManager {
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
Ok(None) => log::warn!(
"[PlaybackMode] Previous remote session not found while reading state"
),
Err(e) => log::warn!(
"[PlaybackMode] Failed to read previous remote session: {}",
e
),
}
}
@@ -379,7 +487,10 @@ impl PlaybackModeManager {
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
QueueContext::Album { album_id, album_name } => {
QueueContext::Album {
album_id,
album_name,
} => {
log::info!(
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
album_name,
@@ -387,7 +498,10 @@ impl PlaybackModeManager {
queue_ids.len()
);
}
QueueContext::Playlist { playlist_id, playlist_name } => {
QueueContext::Playlist {
playlist_id,
playlist_name,
} => {
log::info!(
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
playlist_name,
@@ -479,7 +593,11 @@ impl PlaybackModeManager {
return Err("Remote session not found".to_string());
}
Err(e) => {
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
log::warn!(
"[PlaybackMode] Error polling session (attempt {}): {}",
attempts,
e
);
// Continue polling - transient errors are OK
}
}
@@ -511,9 +629,15 @@ impl PlaybackModeManager {
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
log::info!(
"[PlaybackMode] Stopping previous remote session {}",
prev_session_id
);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
log::warn!(
"[PlaybackMode] Failed to stop previous remote session: {}",
e
);
}
}
@@ -533,7 +657,9 @@ impl PlaybackModeManager {
);
}
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
player
.stop()
.map_err(|e| format!("Failed to stop playback: {}", e))?;
// Log queue state AFTER stop (should be unchanged)
{
@@ -552,14 +678,9 @@ impl PlaybackModeManager {
session_id: session_id.to_string(),
});
// Enable remote volume control on Android (intercepts volume buttons)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
// Start the service + remote-volume control (intercepts volume buttons,
// and starts the foreground service that renders the lockscreen card).
self.enable_remote_control();
log::info!("[PlaybackMode] Successfully transferred to remote");
Ok(())
@@ -621,11 +742,20 @@ impl PlaybackModeManager {
};
// Stop remote playback
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
match client.send_session_command(session_id.clone(), "Stop").await {
log::info!(
"[PlaybackMode] Stopping remote playback on session: {}",
session_id
);
match client
.send_session_command(session_id.clone(), "Stop")
.await
{
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
Err(e) => {
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
log::warn!(
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
e
);
// Don't fail the transfer if we can't stop the remote session
// The user is already playing locally, so this is not critical
}
@@ -702,6 +832,84 @@ mod tests {
);
}
/// Capturing emitter so we can assert what `set_mode` broadcasts.
struct CapturingEmitter {
events: Mutex<Vec<PlayerStatusEvent>>,
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
let emitter = Arc::new(CapturingEmitter {
events: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
manager.set_event_emitter(emitter.clone());
(manager, emitter)
}
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
/// the frontend can reconcile its mirror store to this authoritative one.
#[test]
fn test_set_mode_emits_change_event() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3, "one event per real mode change");
match &events[0] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "remote");
assert_eq!(session_id.as_deref(), Some("sess-1"));
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[1] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "local");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[2] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "idle");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
}
/// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions.
#[test]
fn test_set_mode_deduplicates_no_op() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
assert_eq!(
emitter.events.lock().unwrap().len(),
1,
"repeated identical mode set emits only once"
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
@@ -709,7 +917,10 @@ mod tests {
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
assert_eq!(
start_position_ticks_from_seconds(123.45),
Some(1_234_500_000)
);
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
@@ -737,6 +948,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -768,6 +980,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -797,7 +1010,12 @@ mod tests {
fn test_extract_all_jellyfin_ids_from_album() {
// Simulate an album with 5 tracks - all should be extracted
let items: Vec<MediaItem> = (1..=5)
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
.map(|i| {
create_test_item_with_jellyfin_id(
&format!("track_{}", i),
&format!("jf_track_{}", i),
)
})
.collect();
let manager = super::PlaybackModeManager::new(
@@ -831,7 +1049,7 @@ mod tests {
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
create_test_item_with_jellyfin_id("4", "jf_4"),
];
@@ -866,7 +1084,7 @@ mod tests {
// Current item has no Jellyfin ID - should fail
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
];
+5 -5
View File
@@ -1,9 +1,9 @@
pub mod reporter;
pub mod throttle;
pub mod sync_processor;
pub mod throttle;
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use sync_processor::SyncProcessor;
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
+122 -47
View File
@@ -14,7 +14,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
/// Playback context information
#[derive(Debug, Clone)]
pub struct PlaybackContext {
pub context_type: String, // "container" or "single"
pub context_type: String, // "container" or "single"
pub context_id: Option<String>,
}
@@ -65,7 +65,11 @@ impl PlaybackReporter {
///
/// Always updates local DB first, then attempts server sync if online.
/// If server sync fails, operation is queued for retry.
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
pub async fn report(
&self,
operation: PlaybackOperation,
is_online: bool,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
// Always update local DB first (works offline)
@@ -93,7 +97,11 @@ impl PlaybackReporter {
/// Updates local database with playback info
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
playback_context_type, playback_context_id, pending_sync)
@@ -113,12 +121,22 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused: _,
}
| PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
@@ -133,8 +151,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for progress/stop: {}",
item_id
);
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -152,8 +176,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for mark played: {}",
item_id
);
}
}
@@ -163,34 +193,57 @@ impl PlaybackReporter {
/// Syncs to Jellyfin server
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
let client_guard = self.jellyfin_client.lock().await;
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
let client = client_guard
.as_ref()
.ok_or("JellyfinClient not initialized")?;
match operation {
PlaybackOperation::Start { item_id, position_ticks, .. } => {
client.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Start {
item_id,
position_ticks,
..
} => {
client
.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
client.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
).await?;
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
} => {
client
.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
)
.await?;
log::debug!(
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
item_id,
is_paused
);
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
client.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
client
.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
}
@@ -199,12 +252,13 @@ impl PlaybackReporter {
// For now, report as stopped at max position
// TODO: Fetch item runtime from DB or assume 100% completion
let max_ticks = i64::MAX; // Temporary - should be actual runtime
client.report_playback_stopped(
item_id.clone(),
max_ticks,
None,
).await?;
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
client
.report_playback_stopped(item_id.clone(), max_ticks, None)
.await?;
log::info!(
"[PlaybackReporter] Reported mark played to server: {}",
item_id
);
}
}
@@ -214,13 +268,21 @@ impl PlaybackReporter {
/// Queues operation for later sync
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
let (op_name, item_id, payload) = match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
"context_type": context.as_ref().map(|c| &c.context_type),
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
});
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_start",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::Progress { .. } => {
@@ -230,11 +292,18 @@ impl PlaybackReporter {
return Ok(());
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
});
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_stopped",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -253,7 +322,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
Ok(())
@@ -269,7 +341,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
Ok(())
@@ -278,10 +353,10 @@ impl PlaybackReporter {
/// Extracts item_id from operation
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
match operation {
PlaybackOperation::Start { item_id, .. } |
PlaybackOperation::Progress { item_id, .. } |
PlaybackOperation::Stopped { item_id, .. } |
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
PlaybackOperation::Start { item_id, .. }
| PlaybackOperation::Progress { item_id, .. }
| PlaybackOperation::Stopped { item_id, .. }
| PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
}
}
}
@@ -6,8 +6,8 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
@@ -17,9 +17,9 @@ use crate::storage::db_service::RusqliteService;
/// Configuration for sync processor
pub struct SyncConfig {
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
}
impl Default for SyncConfig {
+186 -84
View File
@@ -4,9 +4,9 @@
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jdouble, jfloat, jint};
@@ -17,7 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
/// Global handler for media session commands from Android lockscreen/notification
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> = OnceLock::new();
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
OnceLock::new();
/// Global handler for remote volume changes from Android volume buttons
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> = OnceLock::new();
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
OnceLock::new();
/// Global player controller for autoplay decisions
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
@@ -87,9 +89,9 @@ impl DetectedCodecs {
/// Public function to get detected codecs (for use in repository layer)
pub fn get_detected_codecs() -> Option<(String, String)> {
DETECTED_CODECS.get().map(|codecs| {
(codecs.video_codecs_string(), codecs.audio_codecs_string())
})
DETECTED_CODECS
.get()
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
}
/// Trait for handling media commands from Android MediaSession.
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
let _ = JAVA_VM.set(vm);
// Store the Context as a global reference for later use
let context_global = env
.new_global_ref(context)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global context ref: {}", e)))?;
let context_global = env.new_global_ref(context).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
})?;
let _ = APP_CONTEXT.set(context_global);
// Store the event emitter
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
})?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| PlayerError::playback_failed(format!("Failed to create class name string: {}", e)))?;
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
})?;
let player_class_obj = env
.call_method(
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&player_class_name.into())],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to Class: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
})?;
// Cast to JClass for static method calls
let player_class = JClass::from(player_class_obj);
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
})?;
// Get the singleton instance
let player_obj = env
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
&[],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!(
"Failed to get JellyTauPlayer instance: {}",
e
))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to object: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
})?;
// Create a global reference to keep the player alive
let player_ref = env
.new_global_ref(player_obj)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global ref: {}", e)))?;
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
})?;
Ok(Self {
player_ref,
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
/// Call a void method on the player with no arguments
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(&self.player_ref, method, "()V", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to call {}: {}", method, e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
})?;
Ok(())
}
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
state.is_loaded = false;
}
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
// Create JNI strings for required parameters
let url_jstring = env
.new_string(&url)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create URL string: {}", e)))?;
let url_jstring = env.new_string(&url).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
})?;
let media_id_jstring = env
.new_string(&media_id)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media ID string: {}", e)))?;
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
})?;
let title_jstring = env
.new_string(&title)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create title string: {}", e)))?;
let title_jstring = env.new_string(&title).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
})?;
// Create JNI strings for optional parameters (null if None)
let artist_jstring = match &artist {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artist string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
})?),
None => None,
};
let album_jstring = match &album {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create album string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
})?),
None => None,
};
let artwork_jstring = match &artwork_url {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artwork string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
})?),
None => None,
};
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
MediaType::Video => "video",
MediaType::Audio => "audio",
};
let media_type_jstring = env
.new_string(media_type_str)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media type string: {}", e)))?;
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
})?;
// Serialize subtitles to JSON for passing to Kotlin
let subtitles_json = serde_json::to_string(&media.subtitles)
.unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env
.new_string(&subtitles_json)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e)))?;
let subtitles_json =
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
})?;
// Call loadWithMetadata for MediaSession support (lockscreen controls)
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
env.exception_describe().ok();
env.exception_clear().ok();
}
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
return Err(PlayerError::playback_failed(format!(
"Failed to call loadWithMetadata: {}",
e
)));
}
debug!("[Android] Successfully called loadWithMetadata");
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(stream_index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
})?;
Ok(())
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
})?;
Ok(())
}
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
if !state.state.is_playing() {
return;
}
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
match state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
{
Some(id) => id,
None => return,
}
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString,
media_id: JString,
) {
let state_str: String = env
.get_string(&state)
.map(|s| s.into())
.unwrap_or_default();
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let player_class_obj = env
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
}
// Call startPlaybackService() on the player instance
env.call_method(
&player_obj,
"startPlaybackService",
"()V",
&[],
)
.map_err(|e| format!("Failed to start playback service: {}", e))?;
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
.map_err(|e| format!("Failed to start playback service: {}", e))?;
log::info!("[Android] JellyTauPlaybackService start requested");
Ok(())
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
/// @param initial_volume Initial volume level (0-100)
#[cfg(target_os = "android")]
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
log::info!("[Android] Enabling remote volume control (volume={})", initial_volume);
log::info!(
"[Android] Enabling remote volume control (volume={})",
initial_volume
);
// Ensure the playback service is started first
start_playback_service()?;
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
@@ -1273,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet - nothing to offset.
if service_obj.is_null() {
return Ok(());
}
env.call_method(
&service_obj,
"setPositionOffset",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
+1 -1
View File
@@ -1,7 +1,7 @@
// Autoplay decision logic
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
use serde::{Deserialize, Serialize};
use crate::repository::types::MediaItem;
use serde::{Deserialize, Serialize};
/// Autoplay decision result - determines what happens after playback ends
#[derive(specta::Type, Debug, Clone, Serialize)]
+15 -2
View File
@@ -163,7 +163,12 @@ impl PlayerBackend for NullBackend {
}
fn play(&mut self) -> Result<(), PlayerError> {
if let PlayerState::Paused { media, position, duration } = &self.state {
if let PlayerState::Paused {
media,
position,
duration,
} = &self.state
{
self.state = PlayerState::Playing {
media: media.clone(),
position: *position,
@@ -174,7 +179,12 @@ impl PlayerBackend for NullBackend {
}
fn pause(&mut self) -> Result<(), PlayerError> {
if let PlayerState::Playing { media, position, duration } = &self.state {
if let PlayerState::Playing {
media,
position,
duration,
} = &self.state
{
self.state = PlayerState::Paused {
media: media.clone(),
position: *position,
@@ -370,6 +380,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -428,6 +439,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -480,6 +492,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
+15
View File
@@ -124,6 +124,21 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>,
},
/// The authoritative playback mode changed in the Rust backend.
///
/// The Rust `PlaybackModeManager` is the single source of truth for which
/// device playback commands route to (local vs a remote session). The
/// frontend keeps a mirror store for the UI; without this event that mirror
/// drifts out of sync (e.g. a mode transition happens inside a transfer or a
/// local stop that the frontend never learns about), and controls then route
/// to the wrong device — the classic "it keeps playing on the remote" bug.
/// The frontend reconciles its store to this payload whenever it fires.
PlaybackModeChanged {
/// New mode: "local", "remote", or "idle".
mode: String,
/// Session id when `mode == "remote"`, otherwise `None`.
session_id: Option<String>,
},
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
+21 -6
View File
@@ -67,9 +67,16 @@ pub struct MediaItem {
/// Artists as array of strings (fallback when artist_items not available)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artists: Option<Vec<String>>,
/// Primary image tag for artwork
/// Primary image tag for artwork.
///
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>,
/// Neutral image identifier the frontend resolves to a URL — replaces
/// `primary_image_tag`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_id: Option<String>,
/// Item type (Audio, Movie, Episode, etc.)
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub item_type: Option<String>,
@@ -138,8 +145,12 @@ impl MediaItem {
/// Get the Jellyfin item ID if available
pub fn jellyfin_id(&self) -> Option<&str> {
match &self.source {
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
MediaSource::Remote {
jellyfin_item_id, ..
} => Some(jellyfin_item_id),
MediaSource::Local {
jellyfin_item_id, ..
} => jellyfin_item_id.as_deref(),
MediaSource::DirectUrl { .. } => None,
}
}
@@ -151,9 +162,7 @@ impl MediaItem {
pub fn playback_url(&self) -> String {
match &self.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
MediaSource::Local { file_path, .. } => {
file_path.to_string_lossy().to_string()
}
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
MediaSource::DirectUrl { url } => url.clone(),
}
}
@@ -343,6 +352,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
@@ -378,6 +388,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
@@ -412,6 +423,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
@@ -446,6 +458,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
@@ -487,6 +500,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
@@ -521,6 +535,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: Some("Movie".to_string()),
playlist_id: None,
duration: Some(120.0),
+417 -90
View File
@@ -42,8 +42,8 @@ pub use mpv_backend::MpvBackend;
#[cfg(target_os = "android")]
pub use android::{
MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume,
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Metadata for the lockscreen / media notification.
@@ -53,6 +53,9 @@ pub use android::{
/// poller fills this in from the remote Jellyfin session and pushes it to the
/// notification so the lockscreen stays in sync while casting.
#[derive(Debug, Clone)]
// Fields are read only by the Android MediaSession bridge; on other platforms
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub struct LockscreenMetadata {
pub title: String,
pub artist: String,
@@ -77,6 +80,22 @@ pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), Stri
}
}
/// Set the base offset (seconds) added to positions reported to the Android
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
/// relative and must be shifted back to absolute to match the full duration.
/// Pass 0.0 to clear on exit. No-op off Android.
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::set_position_offset(_offset_seconds);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};
@@ -84,9 +103,11 @@ use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
use crate::jellyfin::JellyfinClient;
use crate::settings::AudioSettings;
use crate::playback_reporting::{
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
};
use crate::repository::MediaRepository;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
use crate::settings::AudioSettings;
/// Central player controller that coordinates playback
pub struct PlayerController {
@@ -157,7 +178,10 @@ impl PlayerController {
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock_safe();
*jellyfin = client;
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
log::info!(
"[PlayerController] Jellyfin client configured: {}",
jellyfin.is_some()
);
}
/// Get a reference to the Jellyfin client (for remote session control)
@@ -180,7 +204,10 @@ impl PlayerController {
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
let mut reporter_guard = self.playback_reporter.lock().await;
*reporter_guard = reporter;
log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some());
log::info!(
"[PlayerController] Playback reporter configured: {}",
reporter_guard.is_some()
);
}
/// Get a reference to the playback reporter (for backend position updates)
@@ -219,7 +246,10 @@ impl PlayerController {
let mut count = self.autoplay_episode_count.lock_safe();
*count += 1;
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
debug!(
"[PlayerController] Autoplay episode count: {}/{}",
*count, max
);
*count >= max
}
@@ -228,7 +258,10 @@ impl PlayerController {
fn reset_autoplay_count(&self) {
let mut count = self.autoplay_episode_count.lock_safe();
if *count > 0 {
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
debug!(
"[PlayerController] Resetting autoplay episode counter (was {})",
*count
);
}
*count = 0;
}
@@ -259,7 +292,10 @@ impl PlayerController {
/// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
@@ -446,7 +482,9 @@ impl PlayerController {
// Get current playback info before stopping
let jellyfin_id = {
let queue = self.queue.lock_safe();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
queue
.current()
.and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
};
let position_ticks = {
@@ -522,7 +560,10 @@ impl PlayerController {
queue.next().cloned()
};
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
debug!(
"[PlayerController] next: {:?}",
next_item.as_ref().map(|i| &i.title)
);
if let Some(item) = next_item {
self.load_and_play(&item)
@@ -554,7 +595,10 @@ impl PlayerController {
queue.previous().cloned()
};
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
debug!(
"[PlayerController] previous: {:?}",
prev_item.as_ref().map(|i| &i.title)
);
if let Some(item) = prev_item {
self.load_and_play(&item)
@@ -707,7 +751,9 @@ impl PlayerController {
timer.update_remaining_seconds();
// Time-based timer expired: stop playback
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
if matches!(timer.mode, SleepTimerMode::Time { .. })
&& timer.remaining_seconds == 0
{
debug!("[SleepTimer] Time-based timer expired, stopping playback");
timer.cancel();
@@ -843,7 +889,10 @@ impl PlayerController {
// Check why playback ended
let end_reason = self.take_end_reason();
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
debug!(
"[PlayerController] on_playback_ended: end_reason={:?}",
end_reason
);
// Only proceed with autoplay logic if track finished naturally
match end_reason {
@@ -907,8 +956,8 @@ impl PlayerController {
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode = current.media_type == MediaType::Video
&& self.is_episode_item(&current).await;
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -936,7 +985,10 @@ impl PlayerController {
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
warn!(
"[PlayerController] Next-episode lookup failed for {}: {}",
jellyfin_id, e
);
None
}
}
@@ -950,11 +1002,14 @@ impl PlayerController {
// Check if auto-play episode limit is reached
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
debug!(
"[PlayerController] Auto-play episode limit reached ({} episodes)",
settings.max_episodes
);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode: next_ep.0, // Repository MediaItem
current_episode: next_ep.0, // Repository MediaItem
next_episode: next_ep.1,
countdown_seconds: settings.countdown_seconds,
auto_advance: settings.enabled && !limit_reached,
@@ -993,10 +1048,16 @@ impl PlayerController {
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
let stale_reason = self.take_end_reason();
if stale_reason.is_some() {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
debug!(
"[PlayerController] Cleared stale end_reason for video: {:?}",
stale_reason
);
}
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
log::info!(
"[PlayerController] on_video_playback_ended: item_id={}",
item_id
);
// Check sleep timer state
let timer_mode = {
@@ -1035,7 +1096,10 @@ impl PlayerController {
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
warn!(
"[PlayerController] Next-episode lookup failed for {}: {}",
item_id, e
);
None
}
};
@@ -1044,7 +1108,10 @@ impl PlayerController {
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
debug!(
"[PlayerController] Auto-play episode limit reached ({} episodes)",
settings.max_episodes
);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
@@ -1077,11 +1144,18 @@ impl PlayerController {
&self,
item_id: &str,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
) -> Result<
Option<(
crate::repository::types::MediaItem,
crate::repository::types::MediaItem,
)>,
String,
> {
use crate::repository::types::GetItemsOptions;
// Get the current item details from repository
let current_repo_item = repo.get_item(item_id)
let current_repo_item = repo
.get_item(item_id)
.await
.map_err(|e| format!("Failed to get current item: {}", e))?;
@@ -1089,7 +1163,9 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
log::info!(
"[PlayerController] Current item has no season_id, cannot find next episode"
);
return Ok(None);
}
};
@@ -1103,7 +1179,8 @@ impl PlayerController {
..Default::default()
};
let result = repo.get_items(&season_id, Some(options))
let result = repo
.get_items(&season_id, Some(options))
.await
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
@@ -1111,26 +1188,45 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
log::info!(
"[PlayerController] Season has {} episodes, looking for next after {}",
episodes.len(),
current_repo_item.id
);
// Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1];
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
log::info!(
"[PlayerController] Found next episode: {} (index {})",
next.name,
current_idx + 1
);
return Ok(Some((current_repo_item, next.clone())));
} else {
log::info!("[PlayerController] Current episode is the last in the season");
}
} else {
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
log::info!(
"[PlayerController] Current episode not found in season episodes (ids: {:?})",
episodes
.iter()
.map(|e| e.id.as_str())
.take(20)
.collect::<Vec<_>>()
);
}
Ok(None)
}
/// Start autoplay countdown thread
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
pub fn start_autoplay_countdown(
&self,
_next_item: crate::repository::types::MediaItem,
countdown_seconds: u32,
) {
// Create cancellation flag
let cancel_flag = Arc::new(Mutex::new(false));
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
@@ -1169,7 +1265,11 @@ impl Default for PlayerController {
fn default() -> Self {
let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new());
Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler)
Self::new(
Box::new(NullBackend::new()),
playback_reporter,
position_throttler,
)
}
}
@@ -1297,6 +1397,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -1332,8 +1433,16 @@ mod tests {
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
assert_eq!(
queue_lock.current_index(),
Some(0),
"Should start at index 0"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_0",
"Current item should be item_0"
);
}
// Skip to next track
@@ -1343,15 +1452,35 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after skip"
);
assert_eq!(
queue_lock.current_index(),
Some(1),
"Index should advance to 1"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_1",
"Current item should be item_1"
);
// Verify all original items are still present
let current_items = queue_lock.items();
for (i, original) in items_clone.iter().enumerate() {
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i);
assert_eq!(
current_items[i].id, original.id,
"Item {} should still be in queue",
i
);
assert_eq!(
current_items[i].title, original.title,
"Item {} title should be unchanged",
i
);
}
}
@@ -1362,9 +1491,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after second skip"
);
assert_eq!(
queue_lock.current_index(),
Some(2),
"Index should advance to 2"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_2",
"Current item should be item_2"
);
}
// Skip multiple times to reach the end
@@ -1375,9 +1516,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items at end"
);
assert_eq!(
queue_lock.current_index(),
Some(4),
"Index should be at last item (4)"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_4",
"Current item should be item_4"
);
}
}
@@ -1397,7 +1550,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
assert_eq!(
queue_lock.current_index(),
Some(2),
"Should be at last item"
);
}
// Try to skip past the end (without repeat mode)
@@ -1408,7 +1565,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
assert_eq!(
queue_lock.items().len(),
3,
"Queue should still have 3 items after skip at end"
);
// When we skip past the end, the queue index should stay at the last item
// or become None (depending on implementation)
// The key is the queue items themselves should be preserved
@@ -1437,9 +1598,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
assert_eq!(
queue_lock.items().len(),
3,
"Queue should still have 3 items"
);
assert_eq!(
queue_lock.current_index(),
Some(0),
"Should wrap to index 0"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_0",
"Should be back at item_0"
);
}
}
@@ -1456,7 +1629,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
assert_eq!(
queue_lock.current_index(),
Some(3),
"Should start at index 3"
);
}
// Go to previous track
@@ -1466,14 +1643,30 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after previous"
);
assert_eq!(
queue_lock.current_index(),
Some(2),
"Index should move to 2"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_2",
"Current item should be item_2"
);
// Verify all original items are still present
let current_items = queue_lock.items();
for (i, original) in items_clone.iter().enumerate() {
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
assert_eq!(
current_items[i].id, original.id,
"Item {} should still be in queue",
i
);
}
}
}
@@ -1491,15 +1684,27 @@ mod tests {
// Seek to 30 seconds
controller.seek(30.0).unwrap();
assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking");
assert_eq!(
controller.position(),
30.0,
"Position should be 30 after seeking"
);
// Seek to 60 seconds
controller.seek(60.0).unwrap();
assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking");
assert_eq!(
controller.position(),
60.0,
"Position should be 60 after seeking"
);
// Seek backward to 15 seconds
controller.seek(15.0).unwrap();
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
assert_eq!(
controller.position(),
15.0,
"Position should be 15 after seeking backward"
);
}
#[test]
@@ -1518,10 +1723,17 @@ mod tests {
// Seek while paused
controller.seek(45.0).unwrap();
assert_eq!(controller.position(), 45.0, "Position should update while paused");
assert_eq!(
controller.position(),
45.0,
"Position should update while paused"
);
// Verify still paused after seeking
assert!(controller.state().is_paused(), "Should still be paused after seeking");
assert!(
controller.state().is_paused(),
"Should still be paused after seeking"
);
}
#[test]
@@ -1540,10 +1752,17 @@ mod tests {
// Seek while playing
controller.seek(20.0).unwrap();
assert_eq!(controller.position(), 20.0, "Position should update while playing");
assert_eq!(
controller.position(),
20.0,
"Position should update while playing"
);
// Verify still playing after seeking
assert!(controller.state().is_playing(), "Should still be playing after seeking");
assert!(
controller.state().is_playing(),
"Should still be playing after seeking"
);
}
#[test]
@@ -1558,7 +1777,12 @@ mod tests {
for pos in positions {
controller.seek(pos).unwrap();
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
assert_eq!(
controller.position(),
pos,
"Position should match after seeking to {}",
pos
);
}
}
@@ -1575,9 +1799,17 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
assert_eq!(
queue_lock.current_index(),
Some(1),
"Should start at index 1"
);
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
assert_eq!(
controller.position(),
42.5,
"Should resume at the requested position"
);
}
/// A None / near-zero start position starts the track from the beginning.
@@ -1613,7 +1845,11 @@ mod tests {
// Seek back to zero
controller.seek(0.0).unwrap();
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
assert_eq!(
controller.position(),
0.0,
"Should be able to seek to position 0"
);
}
// Autoplay decision tests
@@ -1942,6 +2178,7 @@ mod tests {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
kind: crate::domain::MediaKind::Episode,
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
@@ -1949,11 +2186,13 @@ mod tests {
overview: None,
genres: None,
runtime_ticks: None,
duration_ms: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
image_id: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
@@ -1990,7 +2229,10 @@ mod tests {
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
async fn get_item(
&self,
item_id: &str,
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
@@ -1999,55 +2241,109 @@ mod tests {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_latest_items(
&self,
_: &str,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_resume_items(
&self,
_: Option<&str>,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_next_up_episodes(
&self,
_: Option<&str>,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_recently_played_audio(
&self,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_rediscover_albums(
&self,
_: Option<&str>,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_resume_movies(
&self,
_: Option<usize>,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
async fn get_genres(
&self,
_: Option<&str>,
) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
unimplemented!()
}
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
async fn search(
&self,
_: &str,
_: Option<repo_types::SearchOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
async fn get_playback_info(
&self,
_: &str,
) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
async fn get_live_tv_channels(
&self,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
async fn open_live_stream(
&self,
_: &str,
) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
async fn report_playback_start(
&self,
_: &str,
_: i64,
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
async fn report_playback_progress(
&self,
_: &str,
_: i64,
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
async fn report_playback_stopped(
&self,
_: &str,
_: i64,
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
fn get_image_url(
&self,
_: &str,
_: repo_types::ImageType,
_: Option<repo_types::ImageOptions>,
) -> String {
unimplemented!()
}
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
@@ -2062,16 +2358,31 @@ mod tests {
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
async fn get_person(
&self,
_: &str,
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
unimplemented!()
}
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
async fn get_items_by_person(
&self,
_: &str,
_: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
async fn get_similar_items(
&self,
_: &str,
_: Option<usize>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
async fn create_playlist(
&self,
_: &str,
_: &[String],
) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
@@ -2080,16 +2391,32 @@ mod tests {
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
async fn get_playlist_items(
&self,
_: &str,
) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
async fn add_to_playlist(
&self,
_: &str,
_: &[String],
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
async fn remove_from_playlist(
&self,
_: &str,
_: &[String],
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
async fn move_playlist_item(
&self,
_: &str,
_: &str,
_: u32,
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
+42 -20
View File
@@ -1,16 +1,16 @@
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::AudioSettings;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex as TokioMutex;
@@ -104,11 +104,17 @@ impl MpvBackend {
// Detect and configure audio output
let audio_driver = detect_audio_system();
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
info!(
"[MpvBackend] Configuring audio output driver: {}",
audio_driver
);
mpv.set_property("ao", audio_driver.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
message: format!(
"Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
audio_driver, e
),
})?;
// Enable verbose logging for audio initialization
@@ -123,10 +129,9 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
mpv.set_property("video", "no")
.map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -191,7 +196,11 @@ impl MpvBackend {
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
@@ -203,11 +212,16 @@ impl MpvBackend {
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
// Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: if is_paused { "paused" } else { "playing" }.to_string(),
state: if is_paused { "paused" } else { "playing" }
.to_string(),
media_id,
});
}
@@ -303,14 +317,18 @@ impl MpvBackend {
}
// Check if we're playing for progress reporting
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
let is_paused = mpv_for_position
.get_property::<bool>("pause")
.unwrap_or(true);
// Only report progress to server when playing (not paused)
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock_safe();
state.current_media.as_ref()
state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
@@ -333,8 +351,14 @@ impl MpvBackend {
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
Ok(_) => debug!(
"[MpvBackend] Reported progress for {}",
item_id_clone
),
Err(e) => warn!(
"[MpvBackend] Failed to report progress: {}",
e
),
}
}
});
@@ -468,9 +492,7 @@ impl PlayerBackend for MpvBackend {
}
fn position(&self) -> f64 {
self.mpv
.get_property::<f64>("time-pos")
.unwrap_or(0.0)
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
}
fn duration(&self) -> Option<f64> {
+9 -2
View File
@@ -86,7 +86,10 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(100));
let count = *counter.lock().unwrap();
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
assert_eq!(
count, 1,
"Fallback pattern should execute async code successfully"
);
}
/// Test that position update logic works in a thread
@@ -113,7 +116,11 @@ mod tests {
handle.join().unwrap();
let recorded_positions = positions.lock().unwrap();
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
assert_eq!(
recorded_positions.len(),
5,
"Should have recorded 5 position updates"
);
// Verify positions are increasing
for (i, pos) in recorded_positions.iter().enumerate() {
+25 -17
View File
@@ -149,7 +149,10 @@ impl QueueManager {
}
let insert_index = match position {
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
AddPosition::Next => self
.current_index
.map(|i| i + 1)
.unwrap_or(self.items.len()),
AddPosition::End => self.items.len(),
};
@@ -167,10 +170,7 @@ impl QueueManager {
// Regenerate shuffle order if shuffle is on
if self.shuffle {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
}
}
@@ -199,7 +199,8 @@ impl QueueManager {
// Update shuffle order
if self.shuffle {
self.shuffle_order = self.shuffle_order
self.shuffle_order = self
.shuffle_order
.iter()
.filter(|&&i| i != index)
.map(|&i| if i > index { i - 1 } else { i })
@@ -239,7 +240,10 @@ impl QueueManager {
} else if self.repeat == RepeatMode::All {
0
} else {
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
log::debug!(
"[Queue] next() at end of queue (index {}), no next track",
current
);
return None;
}
};
@@ -269,8 +273,11 @@ impl QueueManager {
if let Some(prev) = self.history.pop() {
// Safety check: ensure the history entry is valid
if prev >= self.items.len() {
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
prev, self.items.len());
log::warn!(
"[Queue] Invalid history entry {} (queue has {} items), clearing history",
prev,
self.items.len()
);
self.history.clear();
return None;
}
@@ -334,10 +341,7 @@ impl QueueManager {
self.shuffle = !self.shuffle;
if self.shuffle && !self.items.is_empty() {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
} else {
self.shuffle_order.clear();
}
@@ -371,7 +375,8 @@ impl QueueManager {
true
} else if self.shuffle {
let pos = self.shuffle_order.iter().position(|&i| i == current);
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
pos.map(|p| p + 1 < self.shuffle_order.len())
.unwrap_or(false)
} else {
current + 1 < self.items.len()
}
@@ -473,8 +478,7 @@ impl QueueManager {
// Update shuffle order if shuffle is on
if self.shuffle && !self.shuffle_order.is_empty() {
// Regenerate shuffle order to maintain consistency
self.shuffle_order =
self.generate_shuffle_order(self.items.len(), self.current_index);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
}
true
@@ -486,7 +490,10 @@ impl QueueManager {
if let Some(current_index) = self.current_index {
if let Some(item) = self.items.get_mut(current_index) {
// Only update if it's a Remote source
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
if let MediaSource::Remote {
jellyfin_item_id, ..
} = &item.source
{
item.source = MediaSource::Remote {
stream_url: new_url,
jellyfin_item_id: jellyfin_item_id.clone(),
@@ -544,6 +551,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
+32 -9
View File
@@ -1,3 +1,4 @@
use super::media::MediaItem;
/**
* Media Session Management
*
@@ -7,10 +8,8 @@
*
* See docs/architecture/01-rust-backend.md for the state machine diagram.
*/
use log::info;
use serde::{Deserialize, Serialize};
use super::media::MediaItem;
/// Media session type tracking the high-level playback context
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -98,7 +97,10 @@ impl MediaSessionManager {
/// Start an audio session with a queue
/// Transitions: Idle → Audio(active), Any → Audio(active)
pub fn start_audio_session(&mut self, first_item: MediaItem) {
info!("[MediaSession] Starting audio session: {}", first_item.title);
info!(
"[MediaSession] Starting audio session: {}",
first_item.title
);
self.current = MediaSessionType::Audio {
last_item: Some(first_item),
is_active: true,
@@ -107,7 +109,11 @@ impl MediaSessionManager {
/// Update audio session with new track (during playback)
pub fn update_audio_track(&mut self, item: MediaItem) {
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
if let MediaSessionType::Audio {
last_item,
is_active,
} = &mut self.current
{
info!("[MediaSession] Updating audio track: {}", item.title);
*last_item = Some(item);
*is_active = true;
@@ -171,8 +177,14 @@ impl MediaSessionManager {
/// Advance to next episode in TV session
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
if let MediaSessionType::TvShow {
item, is_active, ..
} = &mut self.current
{
info!(
"[MediaSession] Advancing to next episode: {}",
next_item.title
);
*item = next_item;
*is_active = true;
}
@@ -230,6 +242,7 @@ mod tests {
artist_items: None,
artists: Some(vec!["Test Artist".to_string()]),
primary_image_tag: None,
image_id: None,
item_type: Some("Audio".to_string()),
playlist_id: None,
duration: Some(180.0),
@@ -260,6 +273,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: Some("Movie".to_string()),
playlist_id: None,
duration: Some(7200.0),
@@ -294,7 +308,10 @@ mod tests {
assert!(matches!(
manager.current(),
MediaSessionType::Audio { is_active: true, .. }
MediaSessionType::Audio {
is_active: true,
..
}
));
assert!(manager.should_show_miniplayer());
@@ -307,7 +324,10 @@ mod tests {
manager.audio_session_inactive();
assert!(matches!(
manager.current(),
MediaSessionType::Audio { is_active: false, .. }
MediaSessionType::Audio {
is_active: false,
..
}
));
assert!(manager.should_show_miniplayer()); // Still shows!
@@ -330,7 +350,10 @@ mod tests {
assert!(matches!(
manager.current(),
MediaSessionType::Movie { is_active: true, .. }
MediaSessionType::Movie {
is_active: true,
..
}
));
assert!(manager.should_show_video_player());
+4 -1
View File
@@ -199,7 +199,9 @@ mod tests {
#[test]
fn test_player_state_loading() {
let media = create_test_media_item("item-1", "Test Item");
let state = PlayerState::Loading { media: media.clone() };
let state = PlayerState::Loading {
media: media.clone(),
};
assert!(matches!(state, PlayerState::Loading { .. }));
assert_eq!(state.position(), None);
assert!(!state.is_playing());
@@ -321,6 +323,7 @@ mod tests {
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: Some("Video".to_string()),
playlist_id: None,
duration: Some(100.0),
File diff suppressed because it is too large Load Diff

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