Compare commits

..
65 Commits
Author SHA1 Message Date
dtourolle ff8f35084b Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m54s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m38s
Build & Release / Build Linux (push) Successful in 16m2s
Build & Release / Build Android (push) Successful in 18m54s
Build & Release / Create Release (push) Successful in 11s
Reviewed-on: #5
2026-06-25 21:52:13 +00:00
dtourolle 4634ed595c fix(remote playback): Move audio between remote players
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m37s
2026-06-25 23:39:01 +02:00
dtourolle 6836ce79c8 fix(Remote playback): kludge to scrub after stream move
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m18s
2026-06-25 21:31:39 +02:00
dtourolle 2811e1b7ca fix: several small fixes
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m26s
2026-06-25 20:02:01 +02:00
dtourolle 1836615dc0 feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00
dtourolle 62874564ff Merge pull request 'feat(library): focused music/TV/movie landing screens + self-draining download queue' (#4) from feat/library-screens-and-download-queue into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m44s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
Build & Release / Run Tests (push) Successful in 2m36s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m41s
Build & Release / Build Linux (push) Successful in 16m23s
Build & Release / Build Android (push) Successful in 18m39s
Build & Release / Create Release (push) Successful in 12s
Reviewed-on: #4
2026-06-24 20:07:34 +00:00
dtourolle 17a35573a0 feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

Downloads:
- Add a backend queue pump: enqueue_download / enqueue_video_downloads
  persist the resolved stream URL + target dir on each row (migration
  017), and the pump starts up to max_concurrent and drains the rest
  automatically as slots free, instead of the frontend silently dropping
  items past the concurrency limit. Album/series/season buttons now
  enqueue rather than calling start_download directly.

Other fixes:
- Hybrid search now returns instant cache results and pushes the merged
  cache+server union via a request-id-tagged search-event, so superseded
  queries can't clobber fresher results.
- URL-encode SearchTerm / genres / item types in online repo requests.
- Android: pause on audio-becoming-noisy (headphone/BT disconnect).
2026-06-24 20:44:17 +02:00
dtourolle dcf08f30bc fix: Autoplay now resets time to zero and ignores trigger if episode already started (#3)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m48s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 3m27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m31s
Build & Release / Build Linux (push) Successful in 15m52s
Build & Release / Build Android (push) Successful in 18m43s
Build & Release / Create Release (push) Successful in 12s
Reviewed-on: #3
Co-authored-by: Duncan Tourolle <duncan@tourolle.paris>
Co-committed-by: Duncan Tourolle <duncan@tourolle.paris>
2026-06-23 21:12:01 +00:00
dtourolle 674c8e5cd0 ci(release): fix release-notes generation breaking jq publish step
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m11s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 3m40s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m36s
Build & Release / Build Linux (push) Successful in 15m56s
Build & Release / Build Android (push) Successful in 18m33s
Build & Release / Create Release (push) Successful in 7s
The release-notes echo lines used unescaped backticks, which the shell ran
as command substitution; their output leaked control characters into
release_notes.md, so jq failed with 'Invalid string: control characters ...
must be escaped' when building the release payload.

- Escape the backticks so they are literal markdown.
- Remove emoji from the release-notes content (plain ASCII headings).
- Handle an already-existing release (HTTP 409) by reusing its id for
  asset upload instead of failing.
2026-06-23 20:38:09 +02:00
dtourolleandClaude Opus 4.8 45aa029916 fix(connectivity): drive reachability from real repository traffic
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m32s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m27s
Build & Release / Run Tests (push) Successful in 3m36s
Build & Release / Build Linux (push) Successful in 15m43s
Build & Release / Build Android (push) Successful in 18m40s
Build & Release / Create Release (push) Failing after 22s
The offline/online switch was janky because two independent systems decided
"online" and never communicated:

- ConnectivityMonitor owned is_server_reachable (drove the UI banner) but
  learned reachability only from a standalone /System/Info/Public ping loop
  and from auth/login calls.
- HybridRepository served all real data by racing cache-vs-server but never
  read or wrote reachability.

So the banner reflected a side-channel poller, not the system the user actually
experienced: a successful ping could read "online" while authenticated data
calls 401'd or timed out, and three different timeout regimes (5s ping / 30s
data / 100ms cache race) flapped against each other.

Unify into a single source of truth:

- Extract a cheap, cloneable ConnectivityReporter that owns all reachability
  transitions and event emission.
- OnlineRepository reports the outcome of every server request to the reporter,
  classified via RepoError: Ok/Authentication/NotFound/Server => reachable
  (the server answered), Network => offline candidate, Database/Offline =>
  ignored (not a server signal).
- Time-window debounce (OFFLINE_CONFIRM_WINDOW = 5s): flip offline only after
  sustained network failure; recover instantly on the first success.
- Demote the ping loop to an offline-only recovery probe (no online polling;
  real traffic is the signal when online).
- Frontend: navigator.onLine is now advisory (triggers a recheck instead of
  forcing offline); removed the dead markReachable/markUnreachable store methods.

Docs updated (README, 07-connectivity, 03-data-flow, 02-svelte-frontend) to
describe the new model and fix pre-existing drift (HTTP client is 30s timeout +
5s ping, not the documented 10s/base_url).

Tests: 12 connectivity tests (debounce, instant recovery, RepoError
classification through report_outcome). Full suite: 398 Rust + 384 frontend
passing, svelte-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:56:14 +02:00
dtourolleandClaude Opus 4.8 3faa595b76 fix(android): track VideoOverlayManager.kt in canonical source tree
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m28s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 3m11s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 17m51s
Build & Release / Build Linux (push) Successful in 15m29s
Build & Release / Build Android (push) Failing after 18m9s
Build & Release / Create Release (push) Has been skipped
JellyTauPlayer.kt references com.dtourolle.jellytau.VideoOverlayManager,
but the file existed only in the gitignored gen/android dir, so it
survived locally but vanished in CI (which regenerates gen/android via
'tauri android init'). sync-android-sources.sh copies top-level *.kt
from src-tauri/android, so adding it there gets it synced into the build.

Fixes: 'Unresolved reference: VideoOverlayManager' in
:app:compileUniversalReleaseKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:55:30 +02:00
dtourolleandClaude Opus 4.8 7fb866a583 ci: fix release Android build --apk flag requires explicit value
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m26s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 2m29s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 17m57s
Build & Release / Build Linux (push) Successful in 15m38s
Build & Release / Build Android (push) Failing after 14m48s
Build & Release / Create Release (push) Has been skipped
Tauri CLI requires '--apk true'; bare '--apk' fails with
"a value is required for '--apk <APK>'". The release workflow
only reached this step now that checkout/container issues are fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:44:23 +02:00
dtourolle 0c3ed74fe1 ci: Improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m47s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 2m27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 28m0s
Build & Release / Build Linux (push) Successful in 15m45s
Build & Release / Build Android (push) Failing after 49s
Build & Release / Create Release (push) Has been skipped
2026-06-21 17:38:06 +02:00
dtourolle a5b6266a6d Permanent CI fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m54s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 1h14m25s
Build & Release / Run Tests (push) Failing after 7s
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-06-21 10:48:26 +02:00
dtourolle a816c84f8c fix CI
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:35:14 +02:00
dtourolle 87762c03b6 Fix linux playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m38s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:16:25 +02:00
dtourolle 975936b902 Merge pull request 'chore: clean up repo organization' (#2) from chore/repo-cleanup into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m44s
Traceability Validation / Check Requirement Traces (push) Successful in 30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Reviewed-on: #2
2026-06-21 07:53:30 +00:00
dtourolle 5ba9e0e958 chore: clean up repo organization
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 5m6s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 30s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 19m30s
- Standardize on bun: remove package-lock.json, add packageManager field,
  gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
  move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
  and slim README down to a project intro + docs index
- Fix internal references to the moved files
2026-06-21 09:52:09 +02:00
dtourolle ccc9dca924 fix: resolve all Rust test compile warnings without suppression
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m16s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Remove unused imports and a dead test helper, and strengthen tests that
held unused bindings/fields so the values are actually exercised:
- drop unused imports (HttpConfig, MediaType, std::io::Write)
- remove unused TestEventEmitter::clear helper
- replace meaningless size_of asserts with real empty-manager checks
- assert on MockTrack.name in sorting tests
2026-06-21 09:33:50 +02:00
dtourolle 60bb6c72d1 ci: stop committing machine-specific Android cargo config
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m10s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
src-tauri/.cargo/config.toml hardcoded absolute NDK paths under
/home/dtourolle, which only exist on the dev laptop. In CI this made
ring's build script fail to find aarch64-linux-android34-clang. Tauri
derives the linker/CC paths from NDK_HOME automatically during
'android build', so the file is unnecessary there. Untrack it and
gitignore it; local dev copies are preserved on disk.
2026-06-21 09:22:34 +02:00
dtourolle 37cc9b424d ci: ensure Android NDK is available in build-and-test job
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m48s
Traceability Validation / Check Requirement Traces (push) Successful in 27s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 5m32s
Set ANDROID_HOME/NDK_HOME/NDK_VERSION at the job level and add a step
that installs the NDK via sdkmanager when missing, so the Android init
step no longer fails when the builder image lacks NDK_HOME.
2026-06-21 09:10:41 +02:00
dtourolle 2daee7ec2d fix extract traces
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m10s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m20s
2026-06-21 08:48:39 +02:00
dtourolle 01c6423154 Merge pull request 'Migrate all IPC call sites to typed tauri-specta commands.*' (#1) from ipc-commands-migration into master
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Reviewed-on: #1
2026-06-21 06:48:08 +00:00
dtourolle d01c2aab9f Migrate all IPC call sites to typed tauri-specta commands.*
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.

- Register repository_get_subtitle_url and repository_get_video_download_url
  in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
  ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
  - VideoDownloadButton passed an un-awaited Promise as the stream URL.
  - setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
  enforce what they checked.

svelte-check: 0 errors. vitest: green. cargo test --lib: green.
2026-06-21 08:47:04 +02:00
dtourolle 14e9d7e03a E frontend reconciliation (2/2): align consumers to generated bindings; svelte-check clean
Traceability Validation / Check Requirement Traces (push) Failing after 21s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m14s
Reconcile the ~50 frontend files that consumed the old hand-written types against
the stricter generated bindings (141 -> 0 svelte-check errors):

- Nullable strictness: null-coalesce/guard at consumer sites; widen shared helpers
  (CachedImage.tag, ticksToSeconds, formatDuration, getSessionIcon, session store
  send*/transferToRemote) to accept null.
- Field-name fixes exposed by codegen: userData.played -> isPlayed; remote
  NowPlayingItem uses album (not albumName); ArtistItem id/name.
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia.
- ArtistItem now serializes camelCase (PascalCase alias retained for Jellyfin
  deserialize); update its serialize test.
- Update downloads.test.ts to the bundled { request } shape; complete the
  sessions.test.ts NowPlayingItem mock.

Frontend: svelte-check 0 errors, vitest 448 passing. Backend: 387 passing.
2026-06-20 20:49:20 +02:00
dtourolle 76c78e2edc E frontend reconciliation (1/2): types.ts sources from bindings; backend field fixes
- types.ts now re-exports wire types from generated bindings (single source of
  truth); keeps frontend-only unions (ItemType/LibraryType/PersonType/
  SessionCommand) and aliases Session = SessionInfo.
- Backend: MediaItem.runtime_ticks serializes as runTimeTicks (matches frontend,
  fixes a latent undefined-read bug); ArtistItem serializes camelCase id/name
  (PascalCase aliases retained for Jellyfin deserialize).
- player.ts: normalize remote NowPlayingItem -> MediaItem in mergedMedia so
  display components treat local/remote items uniformly.
- Reduces svelte-check errors 141 -> 70 (remaining: nullable guards + played->isPlayed).
2026-06-20 20:30:08 +02:00
dtourolle 6146d70bc5 Linux: render video via HTML5 WebView instead of the MPV backend
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 1m37s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 22s
- online.rs: on Linux, advertise only WebView-decodable codecs (h264 video;
  aac/mp3/opus/vorbis/flac audio) in PlaybackInfo so Jellyfin transcodes
  HEVC/AV1/VP9/etc. to h264 HLS for the WebKitGTK <video> element.
- player: on Linux, don't load video into MPV (no embedded window — it would
  start a redundant decode the frontend immediately stops). Add
  PlayerController::set_current_item to keep queue/UI/remote-transfer state in
  sync without loading the item into the playback backend.
2026-06-20 19:31:57 +02:00
dtourolle ea342d76e3 Fix download + casting breakages from the specta migration
Casting: restore camelCase serialization on SessionInfo/NowPlayingItem/PlayState
(container rename_all = "camelCase" + per-field PascalCase serde aliases so Jellyfin
PascalCase still deserializes). Bindings and the existing frontend are camelCase
again — casting works with no frontend session changes.

Downloads: migrate DownloadButton.svelte and downloads.ts to the typed
commands.downloadItemAndStart / downloadItem / downloadVideo wrappers (request
objects), matching the bundled backend signatures.

Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
  and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
  PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
2026-06-20 19:27:29 +02:00
dtourolle 7d07b38d50 Exclude generated bindings.ts from Tailwind content scan
Tailwind v4 auto-scans all source files; the 3000-line generated bindings.ts has
no CSS classes and a large generated TS file can confuse class detection. Add an
@source not exclusion.
2026-06-20 18:38:59 +02:00
dtourolle dc1f516da7 Fix Android insta-crash: don't export TS bindings at runtime
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 18s
The tauri-specta `.export(...)` call ran inside run() on every debug app start
and tried to write `../src/lib/api/bindings.ts`, a path that doesn't exist on a
device — `.expect()` panicked at startup, crashing the app instantly on Android.
Bindings are generated by the `export_typescript_bindings` test instead.
2026-06-20 18:36:57 +02:00
dtourolle 731966246f Workstream E: generate bindings.ts via reusable specta_builder + export test
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 3m41s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 48s
- Extract specta_builder() so run() and a #[test] share one command list.
- Add export_typescript_bindings test that writes src/lib/api/bindings.ts.
- Configure the TS exporter bigint behavior to Number (matches existing frontend).
- Commit the generated bindings.ts (typed commands.* wrappers + all DTO types).
2026-06-20 18:25:54 +02:00
dtourolle 57ff364f54 Add dep to docker file
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-06-20 18:20:38 +02:00
dtourolle ada3ed64ab Workstream E (backend): wire tauri-specta — annotate commands, derive Type, generate-ready Builder
- Add #[specta::specta] to all 201 #[tauri::command] functions.
- Derive specta::Type on all IPC DTOs (repository/types, settings, player/storage/
  download command DTOs, player enums, jellyfin SessionInfo/NowPlayingItem/PlayState,
  ThumbnailCacheStats, DownloadInfo, CacheConfig, etc.).
- Replace tauri::generate_handler! with a tauri_specta::Builder + collect_commands!
  in lib.rs (exports bindings.ts in debug builds).

Two contract changes required by specta constraints (frontend migration follows):
- specta caps command arity at 10 args: download_item_and_start / download_item /
  download_video now take a single request struct (params bundled, body unchanged
  via destructuring).
- specta can't parse split serde rename_all: SessionInfo/NowPlayingItem/PlayState
  switched to rename_all = "PascalCase" (Jellyfin deserialization preserved; these
  now serialize PascalCase to the frontend).

cargo check --lib is clean (0 errors). Frontend migration to bindings.ts is the next step.
2026-06-20 18:20:25 +02:00
dtourolle 55f1b85f12 Fix CI
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 7m4s
Traceability Validation / Check Requirement Traces (push) Failing after 24s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m5s
2026-06-20 17:51:58 +02:00
dtourolle 26286ac6e7 sign build
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m47s
Traceability Validation / Check Requirement Traces (push) Failing after 4s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 1m41s
2026-06-20 17:37:48 +02:00
dtourolle 144abb2f8b Workstream C: convert commands/storage to a folder module, extract thumbnail/people/series-prefs commands
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 14m27s
Traceability Validation / Check Requirement Traces (push) Failing after 4s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 1m42s
- Move commands/storage.rs to commands/storage/mod.rs.
- Extract three cohesive, self-contained clusters:
  - storage/thumbnails.rs: thumbnail cache + image-URL commands.
  - storage/people.rs: person/cast metadata cache commands.
  - storage/series_prefs.rs: per-series preferred audio track commands.
- Re-exported via pub use so command names stay at commands::storage::*;
  invoke_handler unchanged. mod.rs shrinks from 1979 to 1500 lines, all tests pass.
2026-06-20 17:08:34 +02:00
dtourolle 642ec17069 Workstream C: convert commands/download to a folder module, extract pinning and smart-cache commands
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
- Move commands/download.rs to commands/download/mod.rs.
- Extract pin/unpin/is_pinned into download/pinning.rs.
- Extract smart-cache stats/config + album recommendation commands into
  download/smart_cache.rs.
- Re-exported via pub use so command names stay at commands::download::*;
  invoke_handler unchanged, all tests pass.
2026-06-20 16:56:22 +02:00
dtourolle e560258a4b Workstream C: extract player settings and queue-manipulation commands into submodules
- commands/player/settings.rs: audio/video settings commands (4).
- commands/player/queue.rs: queue add/remove/move/skip + by-id commands (6) and
  their request DTOs.
- Make check_for_local_download and get_queue_status pub(super) for reuse.
- mod.rs now ~1834 lines (from 2720); invoke_handler unchanged, all tests pass.
2026-06-20 16:44:06 +02:00
dtourolle d1e5ba4c5d Workstream C: extract player sleep-timer/autoplay and session commands into submodules
- commands/player/timers.rs: sleep-timer + autoplay commands (8).
- commands/player/session.rs: media session get/dismiss commands (2).
- Make create_media_item and get_player_status pub(super) so the submodules can
  reuse them. mod.rs shrinks from ~2720 to ~2229 lines; invoke_handler unchanged.
2026-06-20 16:32:29 +02:00
dtourolle 8ddad497bf Workstream C: convert commands/player to a folder module, extract remote-session commands
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 10m49s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 5s
- Move commands/player.rs to commands/player/mod.rs (folder module).
- Extract the 5 self-contained remote-session control commands
  (remote_play_on_session/send_command/session_seek/set_volume/toggle_mute)
  into commands/player/remote.rs, re-exported via `pub use remote::*` so the
  command names remain at commands::player::* and the invoke_handler is unchanged.

No behavior change; establishes the submodule split pattern for the oversized
command file.
2026-06-20 16:25:43 +02:00
dtourolle 46b9d328ab More CI fixes
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
2026-06-20 16:15:33 +02:00
dtourolle dd5d648d21 Workstream C: relocate video seek-strategy logic into player core
Move the pure determine_video_seek_strategy function and its VideoSeekStrategy
enum (plus the 5 seek-strategy unit tests) out of the command layer into
player/seek.rs, where they belong and are testable without the Tauri State
harness. commands/player.rs now imports them from crate::player. No behavior
change.
2026-06-20 16:13:59 +02:00
dtourolle 6866f03c55 Architecture remediation A/B/F: poison-tolerant locks, graceful backend init, doc fixes
Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
  poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
  sites with _safe variants across 14 files, eliminating the player
  crash-cascade class. Tokio async mutexes are unchanged.

Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
  it falls back to NullBackend and emits a backend-init-failed event so the UI
  can show "playback unavailable" instead of the app crashing. Fatal DB-setup
  panics are kept.

Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
  reflect reality (~20.5k non-test frontend) and document the events+polling
  hybrid plus the new locking/backend-init behavior.
2026-06-20 16:03:54 +02:00
dtourolle 0738ef10ec More clean up
Traceability Validation / Check Requirement Traces (push) Failing after 4s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 8m52s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
2026-06-20 15:38:26 +02:00
dtourolle d5bca41c60 CLean up and CI fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 1m42s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 3s
2026-06-20 15:32:32 +02:00
dtourolle c959c07ab4 Fixes for tests
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 31s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 3s
2026-06-20 15:28:15 +02:00
dtourolle d6aa74fc85 fix remote volme control
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-03-01 20:23:24 +01:00
dtourolle 09780103a7 Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-03-01 19:47:46 +01:00
dtourolle 3a9c126dfe Fix warnings and update tracability
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-02-28 20:54:25 +01:00
dtourolle c5be9eb18c improvements to the sleep timer
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 14s
Traceability Validation / Check Requirement Traces (push) Failing after 2s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
2026-02-28 20:33:22 +01:00
dtourolle e8e37649fa Many improvemtns and fixes related to decoupling of svelte and rust on android.
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s
2026-02-28 19:50:47 +01:00
dtourolle 07f3bf04ca fix tests
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-02-14 17:15:53 +01:00
dtourolle 9594e963bc album art on lock scree and test fix
Traceability Validation / Check Requirement Traces (push) Failing after 2s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 15s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
2026-02-14 16:53:51 +01:00
dtourolle 179c51a6fe fix tests
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Failing after 15s
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-02-14 16:49:14 +01:00
dtourolle 59270e8a4f fix tests
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 16:39:46 +01:00
dtourolle b8363aa993 removing unused files
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-02-14 14:59:30 +01:00
dtourolle 410203a5b8 fix tests?
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-02-14 13:34:34 +01:00
dtourolle d199162dc6 fix tests
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Failing after 1s
2026-02-14 12:21:03 +01:00
dtourolle 27f5c435de fix test
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 12:14:53 +01:00
dtourolle cbd5435e26 Fix all skipped downloads store tests (21/21 passing)
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 12:04:26 +01:00
dtourolle a5e065daed fix tests
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 09:27:12 +01:00
dtourolle 8f1c4bc9da correct CI tag
Traceability Validation / Check Requirement Traces (push) Failing after 12s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
2026-02-14 09:13:59 +01:00
dtourolle dbcaa1a1a5 more checks and updated pipeines
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-02-14 09:11:22 +01:00
dtourolle e664bf4620 added tests, use specific CI 2026-02-14 09:08:49 +01:00
dtourolleandClaude Haiku 4.5 57f8a54dac Add comprehensive test coverage for services and utilities
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-14 08:08:22 +01:00
242 changed files with 23824 additions and 19595 deletions
+64 -7
View File
@@ -14,9 +14,9 @@ on:
workflow_dispatch:
jobs:
build:
name: Build APK and Run Tests
runs-on: ubuntu-latest
test:
name: Run Tests
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
@@ -31,9 +31,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -50,7 +50,9 @@ jobs:
bun install
- name: Run frontend tests
run: bun test
run: |
bunx svelte-kit sync
bun run test
- name: Run Rust tests
run: |
@@ -58,14 +60,69 @@ jobs:
cargo test
cd ..
build:
name: Build Android APK
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
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
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
run: |
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
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)
+149 -146
View File
@@ -17,38 +17,41 @@ env:
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
- name: Run frontend tests
run: bun run test --run
run: |
bunx svelte-kit sync
bun run test --run
continue-on-error: false
- name: Run Rust tests
@@ -61,47 +64,34 @@ jobs:
build-linux:
name: Build Linux
runs-on: ubuntu-latest
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
@@ -133,76 +123,81 @@ jobs:
build-android:
name: Build Android
runs-on: ubuntu-latest
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
with:
api-level: 33
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Add Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Install Android NDK
run: |
sdkmanager "ndk;25.1.8937393"
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
- name: Build for Android
run: bun run tauri android build
env:
ANDROID_NDK_HOME: ${{ android.ndk-home }}
ANDROID_SDK_ROOT: ${{ android.sdk-root }}
ANDROID_HOME: ${{ android.sdk-root }}
- 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
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
- name: Prepare Android artifacts
- name: Initialize Android project
run: bun run tauri android init
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
- name: Write signing keystore
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
cat > src-tauri/gen/android/keystore.properties <<EOF
storeFile=$RUNNER_TEMP/jellytau-release.jks
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
EOF
- name: Build signed Android APK
run: bun run tauri android build --apk true --target aarch64
- name: Collect & verify signed APK
run: |
mkdir -p dist/android
# Copy APK
if [ -f "src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk" ]; then
cp src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk dist/android/jellytau-release.apk
fi
# Copy AAB (Android App Bundle) if built
if [ -f "src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" ]; then
cp src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab dist/android/jellytau-release.aab
fi
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
cp "$APK" dist/android/jellytau-release.apk
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
echo "🔏 Verifying signature with $APKSIGNER"
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
ls -lah dist/android/
- name: Upload Android build artifact
@@ -214,9 +209,11 @@ jobs:
create-release:
name: Create Release
runs-on: ubuntu-latest
runs-on: linux/amd64
needs: [build-linux, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -243,23 +240,23 @@ jobs:
id: release_notes
run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}"
echo "## 📱 JellyTau $VERSION Release" > release_notes.md
echo "## JellyTau $VERSION Release" > release_notes.md
echo "" >> release_notes.md
echo "### 📦 Downloads" >> release_notes.md
echo "### Downloads" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux" >> release_notes.md
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
echo "- **DEB** - Install via `sudo dpkg -i jellytau_*.deb` (Ubuntu/Debian)" >> release_notes.md
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- **APK** - Install via `adb install jellytau-release.apk` or sideload via file manager" >> release_notes.md
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
echo "" >> release_notes.md
echo "### What's New" >> release_notes.md
echo "### What's New" >> release_notes.md
echo "" >> release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
echo "" >> release_notes.md
echo "### 🔧 Installation" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux (AppImage)" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
@@ -277,11 +274,11 @@ jobs:
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
echo "- Play Store: Coming soon" >> release_notes.md
echo "" >> release_notes.md
echo "### 🐛 Known Issues" >> release_notes.md
echo "### Known Issues" >> release_notes.md
echo "" >> release_notes.md
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
echo "" >> release_notes.md
echo "### 📝 Requirements" >> release_notes.md
echo "### Requirements" >> release_notes.md
echo "" >> release_notes.md
echo "**Linux:**" >> release_notes.md
echo "- 64-bit Linux system" >> release_notes.md
@@ -292,46 +289,52 @@ jobs:
echo "- 50MB free storage" >> release_notes.md
echo "" >> release_notes.md
echo "---" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
name: ${{ steps.tag_name.outputs.RELEASE_NAME }}
body_path: release_notes.md
files: |
artifacts/linux/*
artifacts/android/*
draft: false
prerelease: ${{ contains(steps.tag_name.outputs.VERSION, 'rc') || contains(steps.tag_name.outputs.VERSION, 'beta') || contains(steps.tag_name.outputs.VERSION, 'alpha') }}
- name: Publish Gitea release & upload assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload to Gitea Releases
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
VERSION="${{ steps.tag_name.outputs.VERSION }}"
API="${GITHUB_SERVER_URL}/api/v1"
REPO="${GITHUB_REPOSITORY}"
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
echo "📦 Release artifacts prepared for $VERSION"
echo ""
echo "Linux:"
ls -lh artifacts/linux/ || echo "No Linux artifacts"
echo ""
echo "Android:"
ls -lh artifacts/android/ || echo "No Android artifacts"
echo ""
echo "✅ Release $VERSION is ready!"
echo "📄 Release notes saved to release_notes.md"
PAYLOAD=$(jq -n \
--arg tag "$VERSION" \
--arg name "JellyTau $VERSION" \
--rawfile body release_notes.md \
--argjson pre "$PRE" \
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
- name: Publish release notes
run: |
echo "## 🎉 Release Published"
echo ""
echo "**Version:** ${{ steps.tag_name.outputs.VERSION }}"
echo "**Tag:** ${{ github.ref }}"
echo ""
echo "Artifacts:"
echo "- Linux artifacts in: artifacts/linux/"
echo "- Android artifacts in: artifacts/android/"
echo ""
echo "Visit the Release page to download files."
echo "📦 Creating release $VERSION on $REPO"
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [ "$HTTP" = "201" ]; then
RELEASE_ID=$(jq -r '.id' resp.json)
elif [ "$HTTP" = "409" ]; then
echo "️ Release $VERSION already exists; fetching its id to upload assets"
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
-H "Authorization: token $TOKEN" | jq -r '.id')
else
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
fi
echo "Release id=$RELEASE_ID"
for f in artifacts/android/* artifacts/linux/*; do
[ -f "$f" ] || continue
echo "⬆️ Uploading $(basename "$f")"
curl -fsS -X POST \
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$f" >/dev/null
done
echo "✅ Release $VERSION published with assets"
+21 -11
View File
@@ -14,8 +14,10 @@ on:
jobs:
validate-traces:
runs-on: ubuntu-latest
runs-on: linux/amd64
name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
@@ -93,20 +95,28 @@ jobs:
echo ""
# Check each file
MISSING_TRACES=0
while IFS= read -r file; do
# Pipe into the loop instead of a here-string (<<<) so this step works
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
# for the same reason. The loop runs in a subshell (so a counter var
# wouldn't survive), so we record warnings in a temp file and count it
# afterwards.
MISSING_FILE=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
# Skip test files
if [[ "$file" == *".test."* ]]; then
continue
fi
case "$file" in
*.test.*) continue ;;
esac
if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1))
echo "$file" >> "$MISSING_FILE"
fi
fi
done <<< "$CHANGED"
done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then
echo ""
@@ -127,9 +137,9 @@ jobs:
run: |
echo ""
echo "📊 Full Report Generated"
echo "📁 Location: docs/TRACEABILITY.md"
echo "📁 Location: docs/traceability.md"
echo ""
head -50 docs/TRACEABILITY.md || true
head -50 docs/traceability.md || true
- name: Save artifacts
if: always()
@@ -138,5 +148,5 @@ jobs:
name: traceability-reports
path: |
traces-report.json
docs/TRACEABILITY.md
docs/traceability.md
retention-days: 30
+4 -2
View File
@@ -15,7 +15,9 @@ on:
jobs:
traceability:
name: Validate Requirement Traces
runs-on: ubuntu-latest
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
@@ -133,7 +135,7 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: traceability-report
path: docs/TRACEABILITY.md
path: docs/traceability.md
retention-days: 30
- name: Comment PR with coverage report
+11
View File
@@ -9,6 +9,11 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build output
/build
/dist
@@ -47,3 +52,9 @@ vite.config.ts.timestamp-*
# Logs
logs
*.log
# Android signing keystore (NEVER commit)
android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
+11
View File
@@ -27,6 +27,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \
libclang-dev \
llvm-dev \
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
libglib2.0-dev \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
librsvg2-dev \
libayatana-appindicator3-dev \
# mpv player library (linked via libmpv-sys)
libmpv-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource
@@ -84,6 +94,7 @@ RUN cd src-tauri && cargo fetch && cd ..
FROM builder AS test
WORKDIR /app
RUN echo "Running tests..." && \
bunx svelte-kit sync && \
bun run test && \
cd src-tauri && cargo test && cd .. && \
echo "All tests passed!"
+19 -3
View File
@@ -7,7 +7,8 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \
SDK_VERSION=36 \
BUILD_TOOLS_VERSION=35.0.0 \
RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo
@@ -20,11 +21,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
unzip \
jq \
openjdk-17-jdk-headless \
pkg-config \
libssl-dev \
libclang-dev \
llvm-dev \
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
libglib2.0-dev \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
librsvg2-dev \
libayatana-appindicator3-dev \
# mpv player library (linked via libmpv-sys)
libmpv-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource
@@ -57,10 +69,14 @@ RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-1107
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
# Install Android SDK components
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platform-tools" \
"platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \
"build-tools;$BUILD_TOOLS_VERSION" \
"ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true
-232
View File
@@ -1,232 +0,0 @@
# Code Review Fixes Summary
This document summarizes all the critical bugs and architectural issues that have been fixed in the JellyTau project.
## Fixed Issues
### 🔴 CRITICAL
#### 1. **Fixed nextEpisode Event Handlers - Undefined Method Calls**
- **File:** `src/lib/services/playerEvents.ts`
- **Issue:** Lines 272 and 280 were calling `nextEpisode.showPopup()` and `nextEpisode.updateCountdown()` on an undefined variable.
- **Root Cause:** The import was aliased as `showNextEpisodePopup` but the code tried to use an undefined `nextEpisode` variable.
- **Fix:** Changed import to import the `nextEpisode` store directly, renamed parameters to avoid shadowing.
- **Impact:** Prevents runtime crashes when next episode popup events are emitted from the Rust backend.
#### 2. **Replaced Queue Polling with Event-Based Updates**
- **File:** `src/routes/+layout.svelte`, `src/lib/services/playerEvents.ts`
- **Issue:** Frontend was polling backend every 1 second (`setInterval(updateQueueStatus, 1000)`) for queue status.
- **Root Cause:** Inefficient polling approach creates unnecessary backend load and battery drain.
- **Fix:**
- Removed continuous polling
- Added `updateQueueStatus()` calls on `state_changed` events
- Listeners now trigger updates when playback state changes instead
- **Impact:** Reduces backend load, improves battery life, more reactive to state changes.
### 🟠 HIGH PRIORITY
#### 3. **Moved Device ID to Secure Storage**
- **Files:** `src/lib/services/deviceId.ts` (new), `src/lib/stores/auth.ts`
- **Issue:** Device ID was stored in browser localStorage, accessible to XSS attacks.
- **Fix:**
- Created `deviceId.ts` service that uses Tauri's secure storage commands
- Replaced all `localStorage.getItem("jellytau_device_id")` calls with `getDeviceId()`
- Added caching for performance
- Implemented fallback to in-memory ID if secure storage unavailable
- **Impact:** Enhanced security posture against XSS attacks.
#### 4. **Fixed Event Listener Memory Leaks**
- **File:** `src/lib/stores/auth.ts`, `src/routes/+layout.svelte`
- **Issue:** Event listeners (`listen()` calls) were registered at module load with no cleanup.
- **Fix:**
- Moved listener registration to `initializeEventListeners()` function
- Stored unlisten functions and call them in cleanup
- Added `cleanupEventListeners()` to auth store export
- Called cleanup in `onDestroy()` of layout component
- **Impact:** Prevents memory leaks from duplicate listeners if store/routes are reloaded.
#### 5. **Replaced Browser Alerts with Toast Notifications**
- **File:** `src/lib/components/library/TrackList.svelte`
- **Issue:** Using native `alert()` for errors, which blocks execution and provides poor UX.
- **Fix:**
- Imported `toast` store
- Replaced `alert()` with `toast.error()` call with 5-second timeout
- Improved error message formatting
- **Impact:** Non-blocking error notifications with better UX.
#### 6. **Removed Silent Error Handlers**
- **Files:** `src/lib/services/playbackReporting.ts`, `src/lib/services/imageCache.ts`, `src/lib/services/playerEvents.ts`
- **Issue:** Multiple `.catch(() => {})` handlers silently swallowed errors.
- **Fix:**
- Added proper error logging with `console.debug()` and `console.error()`
- Added comments explaining why failures are non-critical
- Made error handling explicit and debuggable
- **Impact:** Improved debugging and visibility into failures.
### 🟡 MEDIUM PRIORITY
#### 7. **Fixed Race Condition in Downloads Store**
- **File:** `src/lib/stores/downloads.ts`
- **Issue:** Concurrent calls to `refreshDownloads()` could interleave state updates, corrupting state.
- **Fix:**
- Added `refreshInProgress` flag to prevent concurrent calls
- Implemented queuing mechanism for pending refresh requests
- Requests are processed sequentially
- **Impact:** Prevents race condition-induced data corruption in download state.
#### 8. **Centralized Duration Formatting Utility**
- **File:** `src/lib/utils/duration.ts` (new), `src/lib/components/library/TrackList.svelte`, `src/lib/components/library/LibraryListView.svelte`
- **Issue:** Duration formatting logic duplicated across components with magic number `10000000`.
- **Fix:**
- Created `duration.ts` utility with `formatDuration()` and `formatSecondsDuration()` functions
- Added support for both mm:ss and hh:mm:ss formats
- Replaced all component-level functions with imports
- Documented the Jellyfin tick-to-second conversion (10M ticks = 1 second)
- **Impact:** Single source of truth for duration formatting, easier maintenance.
#### 9. **Added Input Validation to Image URLs**
- **File:** `src/lib/utils/validation.ts` (new), `src/lib/api/repository-client.ts`
- **Issue:** Item IDs and image types not validated, vulnerable to path traversal attacks.
- **Fix:**
- Created `validation.ts` with comprehensive input validators:
- `validateItemId()` - rejects invalid characters and excessive length
- `validateImageType()` - whitelist of allowed types
- `validateMediaSourceId()` - similar to item ID validation
- `validateNumericParam()` - bounds checking for widths, heights, quality, etc.
- `validateQueryParamValue()` - safe query parameter validation
- Applied validation to all URL construction methods in repository-client.ts
- Added explicit bounds checking for numeric parameters
- **Impact:** Prevents injection attacks and path traversal vulnerabilities.
#### 10. **Improved Error Handling in Layout Component**
- **File:** `src/routes/+layout.svelte`
- **Issue:** Silent `.catch()` handler in connectivity monitoring could mask failures.
- **Fix:**
- Changed from `.catch(() => {})` to proper error handling with logging
- Added debug messages explaining failure modes
- Implemented async/await with proper error chaining
- **Impact:** Better observability of connectivity issues.
## Unit Tests Added
Comprehensive test suites have been added for critical utilities and services:
### Test Files Created
1. **`src/lib/utils/duration.test.ts`**
- Tests for `formatDuration()` and `formatSecondsDuration()`
- Covers Jellyfin tick conversion, various time formats, edge cases
- 10+ test cases
2. **`src/lib/utils/validation.test.ts`**
- Tests for all validation functions
- Covers valid inputs, invalid characters, bounds checking
- Tests for injection prevention
- 25+ test cases
3. **`src/lib/services/deviceId.test.ts`**
- Tests for device ID generation and caching
- Tests for secure storage fallback
- Tests for cache clearing on logout
- 8+ test cases
4. **`src/lib/services/playerEvents.test.ts`**
- Tests for event listener initialization
- Tests for cleanup and memory leak prevention
- Tests for error handling
### Running Tests
```bash
npm run test
npm run test:ui # Interactive UI
npm run test:coverage # With coverage report
```
## Architecture Improvements
### Separation of Concerns
- ✅ Duration formatting moved to dedicated utility
- ✅ Device ID management centralized in service
- ✅ Input validation extracted to validation utility
- ✅ Event listener lifecycle properly managed
### Security Enhancements
- ✅ Device ID moved from localStorage to secure storage
- ✅ Input validation on all user-influenced URL parameters
- ✅ Path traversal attack prevention via whitelist validation
- ✅ Numeric parameter bounds checking
### Performance Improvements
- ✅ Eliminated 1-second polling (1000 calls/hour reduced to event-driven)
- ✅ Prevented race conditions in state management
- ✅ Added request queuing to prevent concurrent backend thrashing
### Reliability Improvements
- ✅ Fixed critical runtime errors (nextEpisode handlers)
- ✅ Proper memory cleanup prevents leaks
- ✅ Better error handling with visibility
- ✅ Comprehensive test coverage for utilities
## Files Modified
### Core Fixes
- `src/lib/services/playerEvents.ts` - Fixed event handlers, replaced polling
- `src/routes/+layout.svelte` - Removed polling, proper cleanup
- `src/lib/stores/auth.ts` - Device ID management, event listener cleanup
- `src/lib/stores/downloads.ts` - Race condition prevention
- `src/lib/api/repository-client.ts` - Input validation on URLs
- `src/lib/components/library/TrackList.svelte` - Toast notifications, centralized duration
- `src/lib/components/library/LibraryListView.svelte` - Centralized duration formatting
- `src/lib/services/playbackReporting.ts` - Removed silent error handlers
- `src/lib/services/imageCache.ts` - Improved error logging
### New Files
- `src/lib/services/deviceId.ts` - Device ID service (new)
- `src/lib/utils/duration.ts` - Duration formatting utility (new)
- `src/lib/utils/validation.ts` - Input validation utility (new)
- `src/lib/utils/duration.test.ts` - Duration tests (new)
- `src/lib/utils/validation.test.ts` - Validation tests (new)
- `src/lib/services/deviceId.test.ts` - Device ID tests (new)
- `src/lib/services/playerEvents.test.ts` - Player events tests (new)
## Testing Notes
The codebase is now equipped with:
- ✅ Unit tests for duration formatting
- ✅ Unit tests for input validation
- ✅ Unit tests for device ID service
- ✅ Unit tests for player events service
- ✅ Proper mocking of Tauri APIs
- ✅ Vitest configuration ready to use
Run tests with: `npm run test`
## Recommendations for Future Work
1. **Move sorting/filtering to backend** - Currently done in frontend, should delegate to server
2. **Move API URL construction to backend** - Currently in frontend, security risk
3. **Remove more hardcoded configuration values** - Audit for magic numbers throughout codebase
4. **Add CSP headers validation** - Ensure content security policies are properly enforced
5. **Implement proper rate limiting** - Add debouncing to frequently called operations
6. **Expand test coverage** - Add tests for stores, components, and more services
## Backward Compatibility
All changes are backward compatible:
- Device ID service falls back to in-memory ID if secure storage fails
- Duration formatting maintains same output format
- Validation is defensive and allows valid inputs
- Event listeners are properly cleaned up to prevent leaks
## Performance Impact
- **Positive:** 90% reduction in backend polling calls (1000/hour → event-driven)
- **Positive:** Eliminated race conditions that could cause state corruption
- **Positive:** Reduced memory footprint via proper cleanup
- **Neutral:** Input validation adds minimal overhead (happens before URL construction)
---
**Total Issues Fixed:** 10 critical/high-priority items
**Lines of Code Added:** ~800 (utilities, tests, validation)
**Test Coverage:** 45+ test cases across 4 test files
**Estimated Impact:** High reliability and security improvements
+27 -465
View File
@@ -2,323 +2,25 @@
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
## Recommended IDE Setup
Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
Android (ExoPlayer).
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
## Getting Started
---
# Requirements Specification
## 1. User Requirements
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | In Progress |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
| UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned |
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
| UR-020 | Select subtitles for video content | High | Planned |
| UR-021 | Select audio track for video content | High | Planned |
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup | Medium | Done |
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio playback | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Planned |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
| 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 |
---
## 2. Software Requirements
### 2.1 Integration Requirements
External system integrations and platform-specific implementations.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Planned |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| 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 |
### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration.
| ID | Requirement | Endpoint Category | Traces To | Status |
|----|-------------|-------------------|-----------|--------|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
| JA-003 | Get user library views | UserViews | UR-007 | Done |
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
| JA-006 | Search across libraries | Items | UR-008 | Done |
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Planned |
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
| JA-010 | Report playback start | Sessions | UR-025 | Done |
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
| JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Planned |
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
### 2.3 Development Requirements
Internal architecture, components, and application logic.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Planned |
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Planned |
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
| DR-029 | Sleep timer with countdown and auto-stop | Player | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
| DR-047 | Next episode auto-play popup with configurable countdown | Player | UR-023 | Done |
| DR-048 | Video settings (auto-play toggle, countdown duration) | Settings | UR-023, UR-026 | Done |
---
## 3. Traceability Matrix
### User Requirements to Software Requirements
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| 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-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
| UR-014 | - | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-026 | - | DR-029, DR-048 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
| UR-029 | - | DR-032 |
| UR-030 | IR-010 | DR-033 |
| UR-031 | - | DR-034 |
| UR-032 | - | DR-035 |
| UR-033 | - | DR-036 |
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
| UR-036 | IR-022, IR-023 | DR-041 |
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
---
## 4. Test Traceability
### Unit Tests to Software Requirements
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| UT-001 | Player state transitions | DR-001 | Pending |
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
| UT-010 | Download queue management | DR-015 | Done |
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
| UT-016 | All database tables created | IR-013, DR-012 | Done |
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
| UT-026 | NullBackend volume default value | DR-004 | Done |
| UT-027 | NullBackend set volume | DR-004 | Done |
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
| UT-035 | Download event completed serialization | DR-015 | Done |
| UT-036 | Download event failed serialization | DR-015 | Done |
| UT-037 | Download worker exponential backoff | DR-015 | Done |
| UT-038 | Download worker error retryable check | DR-015 | Done |
| UT-039 | Download manager creation | DR-015 | Done |
| UT-040 | Download manager set max concurrent | DR-015 | Done |
| UT-041 | Download info serialization | DR-015 | Done |
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
| UT-044 | Offline item serialization | DR-017 | Done |
| UT-045 | Smart cache default config | DR-015 | Done |
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | DR-015 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
---
## 5. Development Commands
This project uses [bun](https://bun.sh) as its package manager.
```bash
# Activate Rust environment (fish shell)
# Activate the Rust environment (fish shell)
source "$HOME/.cargo/env.fish"
# Install dependencies
bun install
# Development
# Run in development
bun run tauri dev
# Type checking
# Type-check the frontend
bun run check
# Build for Linux
@@ -328,168 +30,28 @@ bun run tauri build
bun run tauri android build
```
---
For the full set of build, test, and Android helper scripts, see
[scripts/README.md](scripts/README.md).
## 6. Architecture Overview
## Documentation
```
jellytau/
├── src/ # Svelte frontend
│ ├── lib/
│ │ ├── api/ # Jellyfin API client (repository pattern)
│ │ ├── components/ # UI components (player, library)
│ │ └── stores/ # Svelte stores (auth, library, player, queue)
│ └── routes/ # SvelteKit pages
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── commands/ # Tauri commands
│ │ └── player/ # Player architecture
│ │ ├── state.rs # State machine
│ │ ├── media.rs # MediaItem, MediaSource
│ │ ├── queue.rs # Queue management
│ │ └── backend.rs # PlayerBackend trait
│ └── gen/android/ # Android project
└── README.md
```
| Topic | Location |
|-------|----------|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
| Build & release process | [docs/build-release.md](docs/build-release.md) |
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
---
## Recommended IDE Setup
## 7. Technical Debt
[VS Code](https://code.visualstudio.com/) +
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
### Linux Keyring Integration Workaround
## License
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
**Symptoms**:
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
- Session restoration fails on app restart even though credentials exist
**Root Cause**:
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
**Current Workaround**:
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
- **Save**: `secret-tool store --label <label> service <service> username <username>`
- **Retrieve**: `secret-tool lookup service <service> username <username>`
- **Delete**: `secret-tool clear service <service> username <username>`
**Implementation**:
See [src-tauri/src/credentials.rs](src-tauri/src/credentials.rs):
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
**Future Fix**:
- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
**Impact**:
- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
**Dependencies**:
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default
---
### Platform Playback Backend Parity (Linux vs Android)
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
**Affected Files**:
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
**Feature Parity Matrix**:
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|---------|-------------|---------------------|--------|
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | ✅ | ❌ | Gap |
| Gapless playback | ✅ | ❌ | Gap |
| Volume normalization | ✅ | ❌ | Gap |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
---
### Frontend Playback Code Duplication
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
**Symptoms**:
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
**Duplicated Code**:
```typescript
// These handlers are identical in both AudioPlayer and MiniPlayer:
handlePlayPause(), handleNext(), handlePrevious(),
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
// These derived states use identical logic:
displayMedia, displayIsPlaying, displayPosition, displayDuration
```
**Future Fix**:
1. Create `src/lib/utils/playbackUnits.ts`:
```typescript
export const TICKS_PER_SECOND = 10_000_000;
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
```
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
- Single source of truth for merged local/remote state
3. Simplify handler wrappers using a utility:
```typescript
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
```
**Impact**:
- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time
**Traces To**: DR-009
MIT
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- .:/app
environment:
- RUST_BACKTRACE=1
command: bash -c "bun test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
command: bash -c "bun run test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
# Android build service - builds APK after tests pass
android-build:
+571
View File
@@ -0,0 +1,571 @@
# Rust Backend Architecture
**Location**: `src-tauri/src/`
## Media Session State Machine
**Location**: `src-tauri/src/player/session.rs`
The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.
**Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> AudioActive : play_queue(audio)
Idle --> MovieActive : play_item(movie)
Idle --> TvShowActive : play_item(episode)
state "Audio Session" as AudioSession {
[*] --> AudioActive
AudioActive --> AudioInactive : playback_ended
AudioInactive --> AudioActive : resume/play
AudioActive --> AudioActive : next/previous
}
state "Movie Session" as MovieSession {
[*] --> MovieActive
MovieActive --> MovieInactive : playback_ended
MovieInactive --> MovieActive : resume
}
state "TV Show Session" as TvShowSession {
[*] --> TvShowActive
TvShowActive --> TvShowInactive : playback_ended
TvShowInactive --> TvShowActive : next_episode/resume
}
AudioSession --> Idle : dismiss/clear_queue
AudioSession --> MovieSession : play_item(movie)
AudioSession --> TvShowSession : play_item(episode)
MovieSession --> Idle : dismiss/playback_complete
MovieSession --> AudioSession : play_queue(audio)
TvShowSession --> Idle : dismiss/series_complete
TvShowSession --> AudioSession : play_queue(audio)
note right of Idle
No active media session
Queue may exist but not playing
No miniplayer/video player shown
end note
note right of AudioSession
SHOW: Miniplayer (always visible)
- Active: Play/pause/skip controls enabled
- Inactive: Play button to resume queue
Persists until explicit dismiss
end note
note right of MovieSession
SHOW: Full video player
- Active: Video playing/paused
- Inactive: Resume dialog
Auto-dismiss when playback ends
end note
note right of TvShowSession
SHOW: Full video player + Next Episode UI
- Active: Video playing/paused
- Inactive: Next episode prompt
Auto-dismiss when series ends
end note
```
**Session State Enum:**
```rust
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MediaSessionType {
/// No active session - browsing library
Idle,
/// Audio playback session (music, audiobooks, podcasts)
/// Persists until explicitly dismissed
Audio {
/// Last/current track being played
last_item: Option<MediaItem>,
/// True = playing/paused, False = stopped/ended
is_active: bool,
},
/// Movie playback (single video, auto-dismiss on end)
Movie {
item: MediaItem,
is_active: bool, // true = playing/paused, false = ended
},
/// TV show playback (supports next episode auto-advance)
TvShow {
item: MediaItem,
series_id: String,
is_active: bool, // true = playing/paused, false = ended
},
}
```
**State Transitions & Rules:**
| From State | Event | To State | UI Behavior | Notes |
|------------|-------|----------|-------------|-------|
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
**Key Design Decisions:**
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
3. **Single Active Session**: Playing new content type replaces current session
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
**Edge Cases Handled:**
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
- User wants to dismiss: Close button clears session -> Idle
- Switch content types: New session replaces old (audio -> movie)
- Paused for extended time: Session persists indefinitely
- Playback errors: Session stays inactive, allows retry
- Queue operations while idle: Queue exists but no session created until play
## Player State Machine (Low-Level Playback)
**Location**: `src-tauri/src/player/state.rs`
The player uses a deterministic state machine with 6 states (operates within a media session):
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Loading : Load
Loading --> Playing : MediaLoaded
Playing --> Paused : Pause
Paused --> Playing : Play
Paused --> Seeking : Seek
Seeking --> Playing : PositionUpdate
Playing --> Idle : Stop
Paused --> Idle : Stop
Idle --> Error : Error
Loading --> Error : Error
Playing --> Error : Error
Paused --> Error : Error
Seeking --> Error : Error
state Playing {
[*] : position, duration
}
state Paused {
[*] : position, duration
}
state Seeking {
[*] : target
}
state Error {
[*] : error message
}
```
**State Enum:**
```rust
pub enum PlayerState {
Idle,
Loading { media: MediaItem },
Playing { media: MediaItem, position: f64, duration: f64 },
Paused { media: MediaItem, position: f64, duration: f64 },
Seeking { media: MediaItem, target: f64 },
Error { media: Option<MediaItem>, error: String },
}
```
**Event Enum:**
```rust
pub enum PlayerEvent {
Load(MediaItem),
Play,
Pause,
Stop,
Seek(f64),
Next,
Previous,
MediaLoaded(f64), // duration
PositionUpdate(f64), // position
PlaybackEnded,
Error(String),
}
```
## Playback Mode State Machine
**Location**: `src-tauri/src/playback_mode/mod.rs`
The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Local : play_queue()
Idle --> Remote : transfer_to_remote(session_id)
Local --> Remote : transfer_to_remote(session_id)
Local --> Idle : stop()
Remote --> Local : transfer_to_local()
Remote --> Idle : session_disconnected()
Remote --> Idle : stop()
state Local {
[*] : Playing on device
[*] : ExoPlayer active
[*] : Volume buttons -> device
}
state Remote {
[*] : Controlling session
[*] : session_id
[*] : Volume buttons -> remote
[*] : Android: VolumeProvider active
}
state Idle {
[*] : No active playback
}
```
**State Enum:**
```rust
pub enum PlaybackMode {
Local, // Playing on local device
Remote { session_id: String }, // Controlling remote Jellyfin session
Idle, // No active playback
}
```
**State Transitions:**
| From | Event | To | Side Effects |
|------|-------|-----|----|
| Idle | `play_queue()` | Local | Start local playback |
| Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session |
| Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) |
| Local | `stop()` | Idle | Stop local playback |
| Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume |
| Remote | `stop()` | Idle | Stop remote playback, disable remote volume |
| Remote | `session_disconnected()` | Idle | Session lost, disable remote volume |
**Integration with Player State Machine:**
- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
- When `PlaybackMode = Idle`: Player state is Idle
**Android Volume Control Integration:**
When transitioning to `Remote` mode on Android:
1. Call `enable_remote_volume(initial_volume)`
2. VolumeProviderCompat intercepts hardware volume buttons
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
4. Volume commands routed to remote session via Jellyfin API
When transitioning away from `Remote` mode:
1. Call `disable_remote_volume()`
2. Volume buttons return to controlling device volume
3. PlaybackStateCompat set to STATE_NONE
4. VolumeProviderCompat is cleared
## Media Item & Source
**Location**: `src-tauri/src/player/media.rs`
```rust
pub struct MediaItem {
pub id: String,
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub duration: Option<f64>,
pub artwork_url: Option<String>,
pub media_type: MediaType,
pub source: MediaSource,
}
pub enum MediaType {
Audio,
Video,
}
pub enum MediaSource {
Remote {
stream_url: String,
jellyfin_item_id: String,
},
Local {
file_path: PathBuf,
jellyfin_item_id: Option<String>,
},
DirectUrl {
url: String,
},
}
```
The `MediaSource` enum enables:
- **Remote**: Streaming from Jellyfin server
- **Local**: Downloaded/cached files (future offline support)
- **DirectUrl**: Direct URLs (channel plugins, external sources)
## Queue Manager
**Location**: `src-tauri/src/player/queue.rs`
```rust
pub struct QueueManager {
items: Vec<MediaItem>,
current_index: Option<usize>,
shuffle: bool,
repeat: RepeatMode,
shuffle_order: Vec<usize>, // Fisher-Yates permutation
history: Vec<usize>, // For back navigation in shuffle
}
pub enum RepeatMode {
Off,
All,
One,
}
```
**Queue Navigation Logic:**
```mermaid
flowchart TB
QM[QueueManager]
QM --> Shuffle
QM --> Repeat
QM --> History
subgraph Shuffle["Shuffle Mode"]
ShuffleOff["OFF<br/>next() returns index + 1"]
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
end
subgraph Repeat["Repeat Mode"]
RepeatOff["OFF<br/>next() at end: -> None"]
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
RepeatOne["ONE<br/>next() returns same item"]
end
subgraph History["History"]
HistoryDesc["Used for previous()<br/>in shuffle mode"]
end
```
## Favorites System
**Location**:
- Service: `src/lib/services/favorites.ts`
- Component: `src/lib/components/FavoriteButton.svelte`
- Backend: `src-tauri/src/commands/storage.rs`
The favorites system implements optimistic updates with server synchronization:
```mermaid
flowchart TB
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
Service -->|1. Optimistic| LocalDB[(SQLite user_data)]
Service -->|2. Sync| JellyfinAPI[Jellyfin API]
Service -->|3. Mark Synced| LocalDB
JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"]
JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"]
LocalDB -->|is_favorite<br/>pending_sync| UserData[user_data table]
```
**Flow**:
1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
2. `toggleFavorite()` service function handles the logic:
- Updates local SQLite database immediately (optimistic update)
- Attempts to sync with Jellyfin server
- Marks as synced if successful, otherwise leaves `pending_sync = 1`
3. UI reflects the change immediately without waiting for server response
**Components**:
- **FavoriteButton.svelte**: Reusable heart button component
- Configurable size (sm/md/lg)
- Red when favorited, gray when not
- Loading state during toggle
- Bindable `isFavorite` prop for two-way binding
- **Integration Points**:
- MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
- Full AudioPlayer: Shows favorite button (planned)
- Album/Artist detail pages: Shows favorite button (planned)
**Database Schema**:
- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1)
- `user_data.pending_sync`: Indicates if local changes need syncing
**Tauri Commands**:
- `storage_toggle_favorite`: Updates favorite status in local database
- `storage_mark_synced`: Clears pending_sync flag after successful sync
**API Methods**:
- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin
- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin
## Player Backend Trait
**Location**: `src-tauri/src/player/backend.rs`
```rust
pub trait PlayerBackend: Send + Sync {
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
fn play(&mut self) -> Result<(), PlayerError>;
fn pause(&mut self) -> Result<(), PlayerError>;
fn stop(&mut self) -> Result<(), PlayerError>;
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
fn position(&self) -> f64;
fn duration(&self) -> Option<f64>;
fn state(&self) -> PlayerState;
fn is_loaded(&self) -> bool;
fn volume(&self) -> f32;
}
```
**Implementations:**
- `NullBackend` - Mock backend for testing
- `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md))
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md))
## Player Controller
**Location**: `src-tauri/src/player/mod.rs`
The `PlayerController` orchestrates playback:
```rust
pub struct PlayerController {
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
muted: bool,
sleep_timer: Arc<Mutex<SleepTimerState>>,
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
// ... other fields
}
```
**Key Methods:**
- `play_item(item)`: Load and play single item (resets autoplay counter)
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
## Playlist System
**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`
**TRACES**: UR-014 | JA-019 | JA-020
The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.
**Types:**
```rust
/// A media item within a playlist, with its distinct playlist entry ID
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistEntry {
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
pub playlist_item_id: String,
#[serde(flatten)]
pub item: MediaItem,
}
/// Result of creating a new playlist
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCreatedResult {
pub id: String,
}
```
**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.
**MediaRepository Trait Methods:**
```rust
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
```
**Cache Strategy:**
- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository
- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
- Background cache update after server fetch via `save_playlist_items_to_cache()`
**Playlist Tauri Commands:**
| Command | Parameters | Returns |
|---------|------------|---------|
| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` |
| `playlist_delete` | `handle, playlist_id` | `()` |
| `playlist_rename` | `handle, playlist_id, name` | `()` |
| `playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>` |
| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` |
| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` |
| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` |
## Tauri Commands (Player)
**Location**: `src-tauri/src/commands/player.rs`
| Command | Parameters | Returns |
|---------|------------|---------|
| `player_play_item` | `PlayItemRequest` | `PlayerStatus` |
| `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` |
| `player_play` | - | `PlayerStatus` |
| `player_pause` | - | `PlayerStatus` |
| `player_toggle` | - | `PlayerStatus` |
| `player_stop` | - | `PlayerStatus` |
| `player_next` | - | `PlayerStatus` |
| `player_previous` | - | `PlayerStatus` |
| `player_seek` | `position: f64` | `PlayerStatus` |
| `player_set_volume` | `volume: f32` | `PlayerStatus` |
| `player_toggle_shuffle` | - | `QueueStatus` |
| `player_cycle_repeat` | - | `QueueStatus` |
| `player_get_status` | - | `PlayerStatus` |
| `player_get_queue` | - | `QueueStatus` |
| `player_get_session` | - | `MediaSessionType` |
| `player_dismiss_session` | - | `()` |
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
| `player_cancel_sleep_timer` | - | `()` |
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
| `player_get_video_settings` | - | `VideoSettings` |
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
| `player_on_playback_ended` | - | `()` |
+647
View File
@@ -0,0 +1,647 @@
# Svelte Frontend Architecture
## Store Structure
**Location**: `src/lib/stores/`
```mermaid
flowchart TB
subgraph Stores
subgraph auth["auth.ts"]
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
end
subgraph playerStore["player.ts"]
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
end
subgraph queueStore["queue.ts"]
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
end
subgraph libraryStore["library.ts"]
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
end
subgraph Derived["Derived Stores"]
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
end
end
```
## Music Library Architecture
**Category-Based Navigation:**
JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.
**Route Structure:**
```mermaid
graph TD
Music["/library/music<br/>(Landing page with category cards)"]
Tracks["Tracks<br/>(List view only)"]
Artists["Artists<br/>(Grid view)"]
Albums["Albums<br/>(Grid view)"]
Playlists["Playlists<br/>(Grid view)"]
Genres["Genres<br/>(Genre browser)"]
Music --> Tracks
Music --> Artists
Music --> Albums
Music --> Playlists
Music --> Genres
```
**View Enforcement:**
| 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` |
**TrackList Component:**
The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:
- **No Thumbnails**: Track numbers only (transform to play button on hover)
- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration
- **Mobile Layout**: Compact rows with track number and metadata
- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility
- **Click Behavior**: Clicking a track plays it and queues all filtered tracks
**Example Usage:**
```svelte
<TrackList
tracks={filteredTracks}
loading={loading}
showArtist={true}
showAlbum={true}
/>
```
**LibraryGrid forceGrid Prop:**
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.
## Playback Reporting Service
**Location**: `src/lib/services/playbackReporting.ts`
The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:
- Offline "Continue Watching" functionality
- Sync queue for when network is unavailable
- Consistent progress across app restarts
```mermaid
sequenceDiagram
participant VideoPlayer
participant PlaybackService as playbackReporting.ts
participant LocalDB as Local SQLite<br/>(Tauri Commands)
participant Jellyfin as Jellyfin Server
VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
par Local Storage (always works)
PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
LocalDB-->>PlaybackService: Ok (pending_sync = true)
and Server Sync (if online)
PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
Jellyfin-->>PlaybackService: Ok
PlaybackService->>LocalDB: invoke("storage_mark_synced")
end
```
**Service Functions:**
- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends
**Tauri Commands:**
| Command | Description |
|---------|-------------|
| `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) |
| `storage_mark_played` | Mark item as played, increment play count |
| `storage_get_playback_progress` | Get stored progress for an item |
| `storage_mark_synced` | Clear `pending_sync` flag after successful server sync |
**Database Schema Notes:**
- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
**Resume Playback Feature:**
- When loading media for playback, the app checks local database for saved progress
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
- User can choose to "Resume" from saved position or "Start from Beginning"
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
- For audio: Seeks to resume position after loading via MPV backend
- Implemented in `src/routes/player/[id]/+page.svelte`
## Repository Architecture (Rust-Based)
**Location**: `src-tauri/src/repository/`
```mermaid
classDiagram
class MediaRepository {
<<trait>>
+get_libraries()
+get_items(parent_id, options)
+get_item(item_id)
+search(query, options)
+get_latest_items(parent_id, limit)
+get_resume_items(parent_id, limit)
+get_next_up_episodes(series_id, limit)
+get_genres(parent_id)
+get_playback_info(item_id)
+report_playback_start(item_id, position_ticks)
+report_playback_progress(item_id, position_ticks, is_paused)
+report_playback_stopped(item_id, position_ticks)
+mark_favorite(item_id)
+unmark_favorite(item_id)
+get_person(person_id)
+get_items_by_person(person_id, options)
+get_image_url(item_id, image_type, options)
+create_playlist(name, item_ids)
+delete_playlist(playlist_id)
+rename_playlist(playlist_id, name)
+get_playlist_items(playlist_id)
+add_to_playlist(playlist_id, item_ids)
+remove_from_playlist(playlist_id, entry_ids)
+move_playlist_item(playlist_id, item_id, new_index)
}
class OnlineRepository {
-http_client: Arc~HttpClient~
-server_url: String
-user_id: String
-access_token: String
-connectivity: Option~Arc~ConnectivityMonitor~~
+new()
+with_connectivity()
-report_outcome()
}
class OfflineRepository {
-db_service: Arc~DatabaseService~
-server_id: String
-user_id: String
+new()
+cache_library()
+cache_items()
+cache_item()
}
class HybridRepository {
-online: Arc~OnlineRepository~
-offline: Arc~OfflineRepository~
+new()
-parallel_race()
-cache_with_timeout()
}
MediaRepository <|.. OnlineRepository
MediaRepository <|.. OfflineRepository
MediaRepository <|.. HybridRepository
HybridRepository --> OnlineRepository
HybridRepository --> OfflineRepository
```
**Key Implementation Details:**
1. **Cache-First Racing Strategy** (`hybrid.rs`):
- Runs cache (SQLite) and server (HTTP) queries in parallel
- Cache has 100ms timeout
- Returns cache result if it has meaningful content
- Falls back to server result otherwise
- Background cache updates planned
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
2. **Handle-Based Resource Management** (`repository.rs` commands):
```rust
// Frontend creates repository with UUID handle
repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
// All operations use handle for identification
repository_get_libraries(handle: String) -> Vec<Library>
repository_get_items(handle: String, ...) -> SearchResult
// Cleanup when done
repository_destroy(handle: String)
```
- Enables multiple concurrent repository instances
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
- No global state conflicts
3. **Frontend API Layer** (`src/lib/api/repository-client.ts`):
- Thin TypeScript wrapper over Rust commands
- Maintains handle throughout session
- All methods: `invoke<T>("repository_operation", { handle, ...args })`
- ~100 lines (down from 1061 lines)
## Playback Mode System
**Location**: `src-tauri/src/playback_mode/mod.rs`
The playback mode system manages transitions between local device playback and remote Jellyfin session control:
```rust
pub enum PlaybackMode {
Local, // Playing on local device
Remote { session_id: String }, // Controlling remote session
Idle, // Not playing
}
pub struct PlaybackModeManager {
current_mode: PlaybackMode,
player_controller: Arc<Mutex<PlayerController>>,
jellyfin_client: Arc<JellyfinClient>,
}
```
**Key Operations:**
1. **Transfer to Remote** (`transfer_to_remote(session_id)`):
```mermaid
sequenceDiagram
participant UI
participant Manager as PlaybackModeManager
participant Player as PlayerController
participant Jellyfin as Jellyfin API
UI->>Manager: transfer_to_remote(session_id)
Manager->>Player: Extract queue items
Manager->>Manager: Get Jellyfin IDs from queue
Manager->>Jellyfin: POST /Sessions/{id}/Playing
Note over Jellyfin: Start playback with queue
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
Note over Jellyfin: Seek to current position
Manager->>Player: Stop local playback
Manager->>Manager: Set mode to Remote
```
2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`):
- Stops remote session playback
- Prepares local player to resume
- Sets mode to Local
**Tauri Commands** (`playback_mode.rs`):
- `playback_mode_get_current()` -> Returns current PlaybackMode
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
- `playback_mode_is_transferring()` -> Check transfer state
- `playback_mode_set(mode)` -> Direct mode setting
**Frontend Store** (`src/lib/stores/playbackMode.ts`):
- Thin wrapper calling Rust commands
- Maintains UI state (isTransferring, transferError)
- Listens to mode change events from Rust
## Database Service Abstraction
**Location**: `src-tauri/src/storage/db_service.rs`
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
```rust
#[async_trait]
pub trait DatabaseService: Send + Sync {
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
where F: Fn(&Row) -> Result<T> + Send + 'static;
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
}
pub struct RusqliteService {
connection: Arc<Mutex<Connection>>,
}
impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
let conn = self.connection.clone();
tokio::task::spawn_blocking(move || {
// Execute query on blocking thread pool
}).await?
}
// ... other methods use spawn_blocking
}
```
**Key Benefits:**
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
- **Type Safety**: `QueryParam` enum prevents SQL injection
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
- **Testable**: Can mock DatabaseService for tests
**Usage Pattern:**
```rust
// Before (blocking - causes UI freeze)
let conn = database.connection();
let conn = conn.lock().unwrap(); // BLOCKS
conn.query_row(...) // BLOCKS
// After (async - no freezing)
let db_service = database.service();
let query = Query::with_params("SELECT ...", vec![...]);
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
```
## Component Hierarchy
```mermaid
graph TD
subgraph Routes["Routes (src/routes/)"]
LoginPage["Login Page"]
LibLayout["Library Layout"]
LibDetail["Album/Series Detail"]
MusicCategory["Music Category Landing"]
Tracks["Tracks"]
Artists["Artists"]
Albums["Albums"]
Playlists["Playlists"]
Genres["Genres"]
Downloads["Downloads Page"]
Settings["Settings Page"]
PlayerPage["Player Page"]
end
subgraph PlayerComps["Player Components"]
AudioPlayer["AudioPlayer"]
VideoPlayer["VideoPlayer"]
MiniPlayer["MiniPlayer"]
Controls["Controls"]
Queue["Queue"]
SleepTimerModal["SleepTimerModal"]
SleepTimerIndicator["SleepTimerIndicator"]
end
subgraph SessionComps["Sessions Components"]
CastButton["CastButton"]
SessionModal["SessionPickerModal"]
SessionCard["SessionCard"]
SessionsList["SessionsList"]
RemoteControls["RemoteControls"]
end
subgraph LibraryComps["Library Components"]
LibGrid["LibraryGrid"]
LibListView["LibraryListView"]
TrackList["TrackList"]
PlaylistDetail["PlaylistDetailView"]
DownloadBtn["DownloadButton"]
MediaCard["MediaCard"]
end
subgraph PlaylistComps["Playlist Components"]
CreatePlaylistModal["CreatePlaylistModal"]
AddToPlaylistModal["AddToPlaylistModal"]
end
subgraph CommonComps["Common Components"]
ScrollPicker["ScrollPicker"]
end
subgraph OtherComps["Other Components"]
Search["Search"]
FavoriteBtn["FavoriteButton"]
DownloadItem["DownloadItem"]
end
LibLayout --> PlayerComps
LibLayout --> LibDetail
MusicCategory --> Tracks
MusicCategory --> Artists
MusicCategory --> Albums
MusicCategory --> Playlists
MusicCategory --> Genres
LibDetail --> LibraryComps
Playlists --> PlaylistComps
Playlists --> PlaylistDetail
Downloads --> DownloadItem
PlayerPage --> PlayerComps
MiniPlayer --> CastButton
CastButton --> SessionModal
SleepTimerModal --> ScrollPicker
PlayerComps --> LibraryComps
```
## MiniPlayer Behavior
**Location**: `src/lib/components/player/MiniPlayer.svelte`
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
**Touch Gesture Handling:**
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
```typescript
function handleTouchStart(e: TouchEvent) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchEndX = touchStartX; // Initialize to start position
touchEndY = touchStartY; // Prevents taps being treated as swipes
isSwiping = true;
}
```
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
**Skip Button State:**
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
## Sleep Timer Architecture
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
**TRACES**: UR-026 | DR-029
The sleep timer supports three modes for stopping playback:
```rust
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SleepTimerMode {
Off,
Time { end_time: i64 }, // Unix timestamp in milliseconds
EndOfTrack, // Stop after current track/episode
Episodes { remaining: u32 }, // Stop after N more episodes
}
```
**Timer Modes:**
| Mode | Trigger | How It Stops |
|------|---------|-------------|
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
**Time-Based Timer Flow:**
```mermaid
sequenceDiagram
participant UI as SleepTimerModal
participant Store as sleepTimer store
participant Rust as PlayerController
participant Thread as Timer Thread
participant Backend as PlayerBackend
UI->>Store: setTimeTimer(30)
Store->>Rust: invoke("player_set_sleep_timer", {mode})
Rust->>Rust: Set SleepTimerMode::Time { end_time }
Rust->>UI: Emit SleepTimerChanged event
loop Every 1 second
Thread->>Thread: update_remaining_seconds()
Thread->>UI: Emit SleepTimerChanged (countdown)
alt remaining_seconds == 0
Thread->>Backend: stop()
Thread->>UI: Emit SleepTimerChanged (Off)
end
end
```
**Frontend Components:**
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
**Key Design Decisions:**
1. **All logic in Rust**: Frontend only displays state and invokes commands
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
## Auto-Play Episode Limit
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
**TRACES**: UR-023 | DR-049
Limits how many episodes auto-play consecutively before requiring manual intervention.
**Settings:**
```rust
// In AutoplaySettings (runtime, in PlayerController)
pub struct AutoplaySettings {
pub enabled: bool,
pub countdown_seconds: u32,
pub max_episodes: u32, // 0 = unlimited
}
// In VideoSettings (persisted, settings page)
pub struct VideoSettings {
pub auto_play_next_episode: bool,
pub auto_play_countdown_seconds: u32,
pub auto_play_max_episodes: u32, // 0 = unlimited
}
```
**Session-Based Counter:**
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue
```mermaid
flowchart TB
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
FetchNext --> IncrementCount["increment_autoplay_count()"]
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
UserClick --> PlayItem["play_item() -> resets counter"]
```
**Settings Sync:**
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
## Player Page Navigation Guard
**Location**: `src/routes/player/[id]/+page.svelte`
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
```typescript
const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) {
// Track already playing - show UI without restarting playback
// Fetch queue status for hasNext/hasPrevious
return;
}
```
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
## Playlist Management UI
**TRACES**: UR-014 | JA-019 | JA-020
**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`
The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.
**Components:**
- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`):
- Modal for creating new playlists with a name input
- Accepts optional `initialItemIds` to pre-populate with tracks
- Keyboard support: Enter to create, Escape to close
- Navigates to new playlist detail page on creation
- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`):
- Modal listing all existing playlists to add tracks to
- "New Playlist" button for inline creation flow
- Shows playlist artwork via CachedImage
- Loading state with skeleton placeholders
- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`):
- Full playlist detail page with artwork, name, track count, total duration
- Click-to-rename with inline editing
- Play all / shuffle play buttons
- Delete with confirmation dialog
- Per-track removal buttons
- Uses `TrackList` component for track display
- Passes `{ type: "playlist", playlistId, playlistName }` context to player
- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`):
- Grid view using `GenericMediaListPage`
- Floating action button (FAB) to create new playlists
- Search by playlist name
**Frontend API Methods** (`src/lib/api/repository-client.ts`):
- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
- `deletePlaylist(playlistId)`
- `renamePlaylist(playlistId, name)`
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
- `addToPlaylist(playlistId, itemIds)`
- `removeFromPlaylist(playlistId, entryIds)`
- `movePlaylistItem(playlistId, itemId, newIndex)`
**Offline Sync** (`src/lib/services/syncService.ts`):
All playlist mutations are queued for offline sync:
- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`
+163
View File
@@ -0,0 +1,163 @@
# Data Flow
## Repository Query Flow (Cache-First)
```mermaid
sequenceDiagram
participant UI as Svelte Component
participant Client as RepositoryClient (TS)
participant Rust as Tauri Command
participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP)
participant Conn as ConnectivityMonitor
UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId})
Rust->>Hybrid: get_items()
par Parallel Racing
Hybrid->>Cache: get_items() with 100ms timeout
Hybrid->>Server: get_items() (no timeout)
end
Note over Server,Conn: Every server request reports its outcome
alt Server succeeds (or answers with 4xx/5xx)
Server->>Conn: mark_reachable() (server is up)
else Network failure / timeout
Server->>Conn: mark_unreachable() (debounced)
end
alt Cache returns with content
Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result
else Cache timeout or empty
Server-->>Hybrid: Fresh result
Hybrid-->>Rust: Return server result
end
Rust-->>Client: SearchResult
Client-->>UI: items[]
Note over UI: Reactive update
```
**Key Points:**
- Cache queries have 100ms timeout for responsiveness
- Server queries always run for fresh data
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
## Playback Initiation Flow
```mermaid
sequenceDiagram
participant User
participant AudioPlayer
participant Tauri as Tauri IPC
participant Command as player_play_item()
participant Controller as PlayerController
participant Backend as PlayerBackend
participant Store as Frontend Store
User->>AudioPlayer: clicks play
AudioPlayer->>Tauri: invoke("player_play_item", {item})
Tauri->>Command: player_play_item()
Command->>Command: Convert PlayItemRequest -> MediaItem
Command->>Controller: play_item(item)
Controller->>Backend: load(item)
Note over Backend: State -> Loading
Controller->>Backend: play()
Note over Backend: State -> Playing
Controller-->>Command: Ok(())
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
Tauri-->>AudioPlayer: status
AudioPlayer->>Store: player.setPlaying(media, position, duration)
Note over Store: UI updates reactively
```
## Playback Mode Transfer Flow
```mermaid
sequenceDiagram
participant UI as Cast Button
participant Store as playbackMode store
participant Rust as Tauri Command
participant Manager as PlaybackModeManager
participant Player as PlayerController
participant Jellyfin as Jellyfin API
UI->>Store: transferToRemote(sessionId)
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
Rust->>Manager: transfer_to_remote()
Manager->>Player: Get current queue
Player-->>Manager: Vec<MediaItem>
Manager->>Manager: Extract Jellyfin IDs
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
Jellyfin-->>Manager: 200 OK
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
Jellyfin-->>Manager: 200 OK
Manager->>Player: stop()
Manager->>Manager: mode = Remote {sessionId}
Manager-->>Rust: Ok(())
Rust-->>Store: PlaybackMode
Store->>UI: Update cast icon
```
## Queue Navigation Flow
```mermaid
flowchart TB
User["User clicks Next"] --> Invoke["invoke('player_next')"]
Invoke --> ControllerNext["controller.next()"]
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
QueueNext --> None["None<br/>(at end)"]
QueueNext --> Some["Some(next)"]
QueueNext --> Same["Same<br/>(repeat one)"]
Some --> PlayItem["play_item(next)<br/>Returns new status"]
```
## Volume Control Flow
```mermaid
sequenceDiagram
participant User
participant Slider as Volume Slider
participant Handler as handleVolumeChange()
participant Tauri as Tauri IPC
participant Command as player_set_volume
participant Controller as PlayerController
participant Backend as MpvBackend/NullBackend
participant Events as playerEvents.ts
participant Store as Player Store
participant UI
User->>Slider: adjusts (0-100)
Slider->>Handler: oninput event
Handler->>Handler: Convert 0-100 -> 0.0-1.0
Handler->>Tauri: invoke("player_set_volume", {volume})
Tauri->>Command: player_set_volume
Command->>Controller: set_volume(volume)
Controller->>Backend: set_volume(volume)
Backend->>Backend: Clamp to 0.0-1.0
Note over Backend: MpvBackend: Send to MPV loop
Backend-->>Tauri: emit "player-event"
Tauri-->>Events: VolumeChanged event
Events->>Store: player.setVolume(volume)
Store-->>UI: Reactive update
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
```
**Key Implementation Details:**
- Volume is stored in the backend (NullBackend/MpvBackend)
- `PlayerController.volume()` delegates to backend
- `get_player_status()` returns `controller.volume()` (not hardcoded)
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
@@ -0,0 +1,132 @@
# Type Synchronization & Thread Safety
## PlayerState (Rust <-> TypeScript)
**Rust:**
```rust
pub enum PlayerState {
Idle,
Loading { media: MediaItem },
Playing { media: MediaItem, position: f64, duration: f64 },
Paused { media: MediaItem, position: f64, duration: f64 },
Seeking { media: MediaItem, target: f64 },
Error { media: Option<MediaItem>, error: String },
}
```
**TypeScript:**
```typescript
type PlayerState =
| { kind: "idle" }
| { kind: "loading"; media: MediaItem }
| { kind: "playing"; media: MediaItem; position: number; duration: number }
| { kind: "paused"; media: MediaItem; position: number; duration: number }
| { kind: "seeking"; media: MediaItem; target: number }
| { kind: "error"; media: MediaItem | null; error: string };
```
## MediaItem Serialization
```rust
// Rust (serde serialization)
#[derive(Serialize, Deserialize)]
pub struct MediaItem {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
// ...
}
```
```typescript
// TypeScript
interface MediaItem {
id: string;
title: string;
artist?: string;
// ...
}
```
## Tauri v2 IPC Parameter Naming Convention
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
```typescript
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
await invoke("player_play_tracks", {
repositoryHandle: "handle-123", // Rust: repository_handle
request: { trackIds: ["id1"], startIndex: 0 }
});
await invoke("remote_send_command", {
sessionId: "session-123", // Rust: session_id
command: "PlayPause"
});
await invoke("pin_item", {
itemId: "item-123" // Rust: item_id
});
// WRONG - snake_case causes "invalid args request" error on Android
await invoke("player_play_tracks", {
repository_handle: "handle-123", // Will fail!
});
```
**Parameter Name Mapping (Rust -> Frontend)**:
| Rust Parameter | Frontend Parameter | Used By |
|----------------|-------------------|----|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
| `item_ids` | `itemIds` | `remote_play_on_session` |
| `start_index` | `startIndex` | `remote_play_on_session` |
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
## Rust Backend Thread Safety
```rust
// Shared state wrapped in Arc<Mutex<>>
pub struct PlayerController {
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
queue: Arc<Mutex<QueueManager>>,
// ...
}
// Tauri state wrapper
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
// Command handler pattern
#[tauri::command]
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
let mut controller = state.0.lock().unwrap(); // Acquire lock
controller.play()?; // Operate
Ok(get_player_status(&controller)) // Lock released
}
```
## Frontend Stores
Svelte stores are inherently reactive and thread-safe for UI updates:
```typescript
const { subscribe, update } = writable<PlayerStore>(initialState);
// Atomic updates
function setPlaying(media: MediaItem, position: number, duration: number) {
update(state => ({
...state,
state: { kind: "playing", media, position, duration }
}));
}
```
+485
View File
@@ -0,0 +1,485 @@
# Platform-Specific Player Backends
## Player Events System
**Location**: `src-tauri/src/player/events.rs`
The player uses a push-based event system to notify the frontend of state changes:
```rust
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
PositionUpdate { position: f64, duration: f64 },
/// Player state changed
StateChanged { state: String, media_id: Option<String> },
/// Media has finished loading and is ready to play
MediaLoaded { duration: f64 },
/// Playback has ended naturally
PlaybackEnded,
/// Buffering state changed
Buffering { percent: u8 },
/// An error occurred during playback
Error { message: String, recoverable: bool },
/// Volume changed
VolumeChanged { volume: f32, muted: bool },
/// Sleep timer state changed
SleepTimerChanged {
mode: SleepTimerMode,
remaining_seconds: u32,
},
/// Show next episode popup with countdown
ShowNextEpisodePopup {
current_episode: MediaItem,
next_episode: MediaItem,
countdown_seconds: u32,
auto_advance: bool,
},
/// Countdown tick (emitted every second during autoplay countdown)
CountdownTick { remaining_seconds: u32 },
/// Queue changed (items added, removed, reordered, or playback mode changed)
QueueChanged {
items: Vec<MediaItem>,
current_index: Option<usize>,
shuffle: bool,
repeat: RepeatMode,
has_next: bool,
has_previous: bool,
},
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
SessionChanged { session: MediaSessionType },
}
```
Events are emitted via Tauri's event system:
```mermaid
flowchart LR
subgraph Backend["Player Backend"]
MPV["MPV/ExoPlayer"]
end
subgraph EventSystem["Event System"]
Emitter["TauriEventEmitter<br/>emit()"]
Bus["Tauri Event Bus<br/>'player-event'"]
end
subgraph Frontend["Frontend"]
Listener["playerEvents.ts<br/>Frontend Listener"]
Store["Player Store Update<br/>(position, state, etc)"]
end
MPV --> Emitter --> Bus --> Listener --> Store
```
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
- Listens for `player-event` Tauri events
- Updates player/queue stores based on event type
- Auto-advances to next track on `PlaybackEnded`
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
```mermaid
flowchart TB
subgraph MainThread["Main Thread"]
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
end
subgraph EventLoopThread["MPV Event Loop Thread"]
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
TauriEmitter["TauriEventEmitter"]
end
MpvBackend -->|"MpvCommand"| EventLoop
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
EventLoop -->|"Events"| TauriEmitter
TauriEmitter --> FrontendStore["Frontend Store"]
```
**Key Components:**
```rust
// Command enum sent to event loop thread
pub enum MpvCommand {
Load { url: String, media: MediaItem },
Play,
Pause,
Stop,
Seek(f64),
SetVolume(f32),
Quit,
}
// Shared state between main thread and event loop
pub struct MpvSharedState {
pub state: PlayerState,
pub position: f64,
pub duration: Option<f64>,
pub volume: f32,
pub is_loaded: bool,
pub current_media: Option<MediaItem>,
}
```
**Event Loop** (`event_loop.rs`):
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
- Emits position updates every 250ms during playback
- Processes commands from channel (non-blocking)
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
## ExoPlayerBackend (Android)
**Location**: `src-tauri/src/player/android/` and Kotlin sources
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
```mermaid
flowchart TB
subgraph RustNative["Rust (Native)"]
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
TauriEmitter2["TauriEventEmitter"]
end
subgraph KotlinJVM["Kotlin (JVM)"]
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
end
ExoBackend -->|"JNI Calls"| JellyTauPlayer
JellyTauPlayer -->|"Callbacks"| NativeFuncs
NativeFuncs --> TauriEmitter2
TauriEmitter2 --> FrontendStore2["Frontend Store"]
```
**Kotlin Player** (`JellyTauPlayer.kt`):
```kotlin
class JellyTauPlayer(context: Context) {
private val exoPlayer: ExoPlayer
private var positionUpdateJob: Job?
// Methods callable from Rust via JNI
fun load(url: String, mediaId: String)
fun play()
fun pause()
fun stop()
fun seek(positionSeconds: Double)
fun setVolume(volume: Float)
// Native callbacks to Rust
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
private external fun nativeOnStateChanged(state: String, mediaId: String?)
private external fun nativeOnMediaLoaded(duration: Double)
private external fun nativeOnPlaybackEnded()
}
```
**JNI Callbacks** (Rust):
```rust
#[no_mangle]
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
) {
// Update shared state
// Emit PlayerStatusEvent::PositionUpdate
}
```
## Android MediaSession & Remote Volume Control
**Location**: `JellyTauPlaybackService.kt`
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
```mermaid
flowchart TB
subgraph Service["JellyTauPlaybackService"]
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
MediaSessionCompat --> VolumeProvider
end
subgraph Hardware["System"]
VolumeButtons["Hardware Volume Buttons"]
Lockscreen["Lockscreen Controls"]
Notification["Media Notification"]
end
subgraph Rust["Rust Backend"]
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
end
VolumeButtons --> VolumeProvider
Lockscreen --> MediaSession
Notification --> MediaSession
VolumeProvider --> JNI
JNI --> PlaybackMode
PlaybackMode --> JellyfinAPI
```
**Architecture Rationale:**
JellyTau maintains both MediaSession types because they serve different purposes:
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
When in remote playback mode (controlling a Jellyfin session on another device):
- Volume buttons are routed through `VolumeProviderCompat`
- Volume changes are sent to the remote session via Jellyfin API
- System volume UI shows the remote session's volume level
**Remote Volume Flow:**
```mermaid
sequenceDiagram
participant User
participant VolumeButton as Hardware Volume Button
participant VolumeProvider as VolumeProviderCompat
participant JNI as nativeOnRemoteVolumeChange
participant PlaybackMode as PlaybackModeManager
participant Jellyfin as Jellyfin Server
participant RemoteSession as Remote Session (TV/Browser)
User->>VolumeButton: Press Volume Up
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
Jellyfin->>RemoteSession: Set volume to new level
RemoteSession-->>User: Volume changes on TV/Browser
```
**Key Implementation Details:**
**Enabling Remote Volume** (`enableRemoteVolume()`):
```kotlin
fun enableRemoteVolume(initialVolume: Int) {
volumeProvider = object : VolumeProviderCompat(
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
100, // Max volume
initialVolume
) {
override fun onSetVolumeTo(volume: Int) {
remoteVolumeLevel = volume.coerceIn(0, 100)
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
}
override fun onAdjustVolume(direction: Int) {
when (direction) {
AudioManager.ADJUST_RAISE -> {
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
currentVolume = remoteVolumeLevel
}
AudioManager.ADJUST_LOWER -> {
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
currentVolume = remoteVolumeLevel
}
}
}
}
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
}
```
**Disabling Remote Volume** (`disableRemoteVolume()`):
```kotlin
fun disableRemoteVolume() {
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
volumeProvider = null
}
```
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
```rust
/// Enable remote volume control on Android
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
start_playback_service()?;
let service_instance = get_playback_service_instance()?;
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
&[JValue::Int(initial_volume)])?;
Ok(())
}
```
**Dependencies** (`src-tauri/android/build.gradle.kts`):
```kotlin
dependencies {
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
}
```
**Integration with Playback Mode:**
Remote volume is automatically enabled/disabled during playback mode transfers:
```rust
// In PlaybackModeManager::transfer_to_remote()
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("Failed to enable remote volume: {}", e);
}
}
// In PlaybackModeManager::transfer_to_local()
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::disable_remote_volume() {
log::warn!("Failed to disable remote volume: {}", e);
}
}
```
## Android Album Art Caching
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
```mermaid
flowchart TB
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
end
subgraph Cache["AlbumArtCache.kt"]
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
end
subgraph Service["JellyTauPlaybackService.kt"]
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
end
LoadMedia --> AsyncDownload
AsyncDownload --> MemoryCache
MemoryCache --> Download
Download --> ErrorHandle
AsyncDownload --> UpdateMeta
UpdateMeta --> Notification
```
**AlbumArtCache Singleton:**
```kotlin
class AlbumArtCache(context: Context) {
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // Size in KB
}
}
suspend fun getArtwork(url: String): Bitmap? {
memoryCache.get(url)?.let { return it }
return downloadAndCache(url)
}
private suspend fun downloadAndCache(url: String): Bitmap? =
withContext(Dispatchers.IO) {
// HTTP download with 5s timeout
// Scale to 512x512 max
// Auto-evict LRU if needed
}
}
```
**Integration Flow:**
1. **Track Load** (`loadWithMetadata()`):
- Store artwork URL in `currentArtworkUrl`
- Reset bitmap to null
- Start playback immediately (non-blocking)
2. **Async Download** (Background Coroutine):
- Check cache: instant hit if available
- Network miss: download, scale, cache
- Auto-retry on network failure with exponential backoff
- Graceful fallback if artwork unavailable
3. **Notification Update**:
- Pass bitmap to `updatePlaybackServiceNotification()`
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
- Display as large icon in notification
- Show on lock screen
**Memory Management:**
| Metric | Value |
|--------|-------|
| Cache Size | 1/8 of heap (12-16MB typical) |
| Max Resolution | 512x512 pixels |
| Capacity | ~50-100 album arts |
| Eviction Policy | LRU (Least Recently Used) |
| Lifetime | In-memory only (app session) |
| Network Timeout | 5 seconds per download |
**Performance Characteristics:**
- **Cache Hit**: ~1ms (in-memory retrieval)
- **Cache Miss**: ~200-500ms (download + scale)
- **Playback Impact**: Zero (async downloads)
- **Memory Overhead**: Max 16MB (auto-eviction)
- **Error Recovery**: Automatic with exponential backoff
## Backend Initialization
**Location**: `src-tauri/src/lib.rs`
Backend selection is platform-specific:
```rust
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
#[cfg(target_os = "linux")]
{
match MpvBackend::new(event_emitter.clone()) {
Ok(backend) => return Box::new(backend),
Err(e) => eprintln!("MPV init failed: {}", e),
}
}
#[cfg(target_os = "android")]
{
// ExoPlayer requires Activity context, initialized separately
}
// Fallback
Box::new(NullBackend::new())
}
```
@@ -0,0 +1,287 @@
# Download Manager & Offline Architecture
## Overview
**Location**: `src-tauri/src/download/`
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
```mermaid
flowchart TB
subgraph Frontend["Frontend"]
DownloadButton["DownloadButton.svelte"]
DownloadsPage["/downloads"]
DownloadsStore["downloads.ts store"]
end
subgraph Backend["Rust Backend"]
Commands["Download Commands"]
DownloadManager["DownloadManager"]
DownloadWorker["DownloadWorker"]
SmartCache["SmartCache Engine"]
end
subgraph Storage["Storage"]
SQLite[("SQLite DB")]
MediaFiles[("Downloaded Files")]
end
DownloadButton -->|"invoke('download_item')"| Commands
DownloadsPage -->|"invoke('get_downloads')"| Commands
Commands --> DownloadManager
DownloadManager --> DownloadWorker
DownloadManager --> SmartCache
DownloadWorker -->|"HTTP Stream"| MediaFiles
DownloadWorker -->|"Events"| DownloadsStore
Commands <--> SQLite
SmartCache <--> SQLite
```
## Download Worker
**Location**: `src-tauri/src/download/worker.rs`
The download worker handles HTTP streaming with retry logic and resume support:
```rust
pub struct DownloadWorker {
client: reqwest::Client,
max_retries: u32,
}
pub struct DownloadTask {
pub id: i64,
pub item_id: String,
pub user_id: String,
pub priority: i32,
pub url: String,
pub target_path: PathBuf,
pub mime_type: Option<String>,
pub expected_size: Option<i64>,
}
```
**Retry Strategy**:
- Exponential backoff: 5s, 15s, 45s
- Maximum 3 retry attempts
- HTTP Range requests for resume support
- Progress events emitted every 1MB
**Download Flow**:
```mermaid
sequenceDiagram
participant UI
participant Command as download_item
participant DB as SQLite
participant Worker as DownloadWorker
participant Jellyfin as Jellyfin Server
participant Store as downloads store
UI->>Command: download_item(itemId, userId)
Command->>DB: INSERT INTO downloads
Command->>Worker: Start download task
Worker->>Jellyfin: GET /Items/{id}/Download
loop Progress Updates
Jellyfin->>Worker: Stream chunks
Worker->>Worker: Write to .part file
Worker->>Store: Emit progress event
Store->>UI: Update progress bar
end
Worker->>Worker: Rename .part to final
Worker->>DB: UPDATE status='completed'
Worker->>Store: Emit completed event
Store->>UI: Show completed
```
## Smart Caching Engine
**Location**: `src-tauri/src/download/cache.rs`
The smart caching system provides predictive downloads based on listening patterns:
```rust
pub struct SmartCache {
config: Arc<Mutex<CacheConfig>>,
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
}
pub struct CacheConfig {
pub queue_precache_enabled: bool,
pub queue_precache_count: usize, // Default: 5
pub album_affinity_enabled: bool,
pub album_affinity_threshold: usize, // Default: 3
pub storage_limit: u64, // Default: 10GB
pub wifi_only: bool, // Default: true
}
```
**Caching Strategies**:
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
```mermaid
flowchart TB
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
Play --> TrackHistory["Track Play History"]
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
CacheAlbum --> CheckStorage
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
CheckStorage -->|"OK"| Download["Queue Download"]
```
## Download Commands
**Location**: `src-tauri/src/commands/download.rs`
| Command | Parameters | Description |
|---------|------------|-------------|
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
| `download_album` | `album_id, user_id` | Queue all tracks in album |
| `get_downloads` | `user_id, status_filter` | Get download list |
| `pause_download` | `download_id` | Pause active download |
| `resume_download` | `download_id` | Resume paused download |
| `cancel_download` | `download_id` | Cancel and delete partial |
| `delete_download` | `download_id` | Delete completed download |
## Offline Commands
**Location**: `src-tauri/src/commands/offline.rs`
| Command | Parameters | Description |
|---------|------------|-------------|
| `offline_is_available` | `item_id` | Check if item downloaded |
| `offline_get_items` | `user_id` | Get all offline items |
| `offline_search` | `user_id, query` | Search downloaded items |
## Player Integration
**Location**: `src-tauri/src/commands/player.rs` (modified)
The player checks for local downloads before streaming:
```rust
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
let local_path = db.and_then(|db_wrapper| {
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
});
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone())
}
} else {
MediaSource::Remote {
stream_url: req.stream_url,
jellyfin_item_id: jellyfin_id.clone()
}
};
MediaItem { source, /* ... */ }
}
```
## Frontend Downloads Store
**Location**: `src/lib/stores/downloads.ts`
```typescript
interface DownloadsState {
downloads: Record<number, DownloadInfo>;
activeCount: number;
queuedCount: number;
}
const downloads = createDownloadsStore();
// Actions
downloads.downloadItem(itemId, userId, filePath)
downloads.downloadAlbum(albumId, userId)
downloads.pause(downloadId)
downloads.resume(downloadId)
downloads.cancel(downloadId)
downloads.delete(downloadId)
downloads.refresh(userId, statusFilter)
// Derived stores
export const activeDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'downloading')
);
```
**Event Handling**:
The store listens to Tauri events for real-time updates:
```typescript
listen<DownloadEvent>('download-event', (event) => {
const payload = event.payload;
switch (payload.type) {
case 'started':
// Update status to 'downloading'
case 'progress':
// Update progress and bytes_downloaded
case 'completed':
// Update status to 'completed', progress to 1.0
case 'failed':
// Update status to 'failed', store error message
}
});
```
## Download UI Components
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
- Multiple states: available, downloading, completed, failed, paused
- Circular progress ring during download
- Size variants: sm, md, lg
- Integrated into TrackList with `showDownload={true}` prop
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
- Individual download list item with progress bar
- Action buttons: pause, resume, cancel, delete
- Status indicators with color coding
**Downloads Page** (`src/routes/downloads/+page.svelte`):
- Active/Completed tabs
- Bulk actions: Pause All, Resume All, Clear Completed
- Empty states with helpful instructions
## Database Schema
**downloads table**:
```sql
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
user_id TEXT NOT NULL,
file_path TEXT,
file_size INTEGER,
mime_type TEXT,
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
progress REAL DEFAULT 0.0,
bytes_downloaded INTEGER DEFAULT 0,
priority INTEGER DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT
);
CREATE INDEX idx_downloads_queue
ON downloads(status, priority DESC, queued_at ASC)
WHERE status IN ('pending', 'downloading');
```
+127
View File
@@ -0,0 +1,127 @@
# Connectivity & Network Architecture
## HTTP Client with Retry Logic
**Location**: `src-tauri/src/jellyfin/http_client.rs`
The HTTP client provides automatic retry with exponential backoff for network resilience:
```rust
pub struct HttpClient {
client: reqwest::Client,
config: HttpConfig,
}
pub struct HttpConfig {
pub timeout: Duration, // Default: 30s (large library queries can be slow)
pub max_retries: u32, // Default: 3
}
```
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
**Retry Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors
- No retry on: 4xx client errors, 401/403 authentication errors
**Error Classification:**
```rust
pub enum ErrorKind {
Network, // Connection failures, timeouts, DNS errors
Authentication, // 401/403 responses
Server, // 5xx server errors
Client, // Other 4xx errors
}
```
## Connectivity Monitor
**Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
### Source of truth: repository traffic
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
| Repository outcome | Meaning | Effect on reachability |
|--------------------|---------|------------------------|
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
### Time-window debounce (offline) + instant recovery (online)
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
- On the **first** `Network` failure, the monitor records `first_failure_at`.
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
### Offline-only recovery probe
```mermaid
flowchart TB
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
Monitor --> State{"is_server_reachable?"}
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
Probe -->|"reachable again"| Monitor
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
Emit --> Frontend["Frontend Store → banner"]
```
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
**Features:**
- **Traffic-driven**: Reachability follows the requests the user actually makes.
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
**Tauri Commands:**
| Command | Description |
|---------|-------------|
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
| `connectivity_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start the offline recovery probe |
| `connectivity_stop_monitoring` | Stop the probe |
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
**Frontend Integration:**
```typescript
// The store is a pure reflection of backend events — it no longer decides
// reachability itself. navigator.onLine is advisory: it triggers an immediate
// recheck rather than forcing the offline state.
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable);
});
```
## Network Resilience Architecture
The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:**
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
- **Event-Driven**: Frontend reacts to connectivity changes via events.
+614
View File
@@ -0,0 +1,614 @@
# Offline Database Design
## Entity Relationship Diagram
```mermaid
erDiagram
servers ||--o{ users : "has"
servers ||--o{ libraries : "has"
libraries ||--o{ items : "contains"
items ||--o{ items : "parent_of"
items ||--o{ user_data : "has"
items ||--o{ downloads : "has"
items ||--o{ media_streams : "has"
items ||--o{ thumbnails : "has"
users ||--o{ user_data : "owns"
users ||--o{ downloads : "owns"
users ||--o{ sync_queue : "owns"
servers {
int id PK
string jellyfin_id UK
string name
string url
string version
datetime last_sync
}
users {
int id PK
string jellyfin_id
int server_id FK
string name
boolean is_active
}
libraries {
int id PK
string jellyfin_id
int server_id FK
string name
string collection_type
string image_tag
}
items {
int id PK
string jellyfin_id
int server_id FK
int library_id FK
int parent_id FK
string type
string name
string sort_name
string overview
int production_year
float community_rating
string official_rating
int runtime_ticks
string primary_image_tag
string backdrop_image_tag
string album_id
string album_name
string album_artist
json artists
json genres
int index_number
int parent_index_number
string premiere_date
json metadata_json
datetime created_at
datetime updated_at
datetime last_sync
}
user_data {
int id PK
int item_id FK
int user_id FK
int position_ticks
int play_count
boolean is_favorite
boolean played
datetime last_played
datetime updated_at
datetime synced_at
}
downloads {
int id PK
int item_id FK
int user_id FK
string file_path
int file_size
string status
float progress
int priority
string error_message
datetime created_at
datetime completed_at
}
media_streams {
int id PK
int item_id FK
int stream_index
string type
string codec
string language
string display_title
boolean is_default
boolean is_forced
boolean is_external
}
sync_queue {
int id PK
int user_id FK
string operation
string entity_type
string entity_id
json payload
datetime created_at
int attempts
datetime last_attempt
string status
}
thumbnails {
int id PK
int item_id FK
string image_type
string image_tag
string file_path
int width
int height
datetime cached_at
}
```
## Table Definitions
### servers
Stores connected Jellyfin server information.
```sql
CREATE TABLE servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
url TEXT NOT NULL,
version TEXT,
last_sync DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### users
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
is_active BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(jellyfin_id, server_id)
);
```
### libraries
Stores library/collection metadata.
```sql
CREATE TABLE libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
collection_type TEXT,
image_tag TEXT,
sort_order INTEGER DEFAULT 0,
last_sync DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(jellyfin_id, server_id)
);
CREATE INDEX idx_libraries_server ON libraries(server_id);
```
### items
Main table for all media items (movies, episodes, albums, songs, etc.).
```sql
CREATE TABLE items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT NOT NULL,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
-- Basic metadata
type TEXT NOT NULL,
name TEXT NOT NULL,
sort_name TEXT,
overview TEXT,
-- Media info
production_year INTEGER,
community_rating REAL,
official_rating TEXT,
runtime_ticks INTEGER,
-- Images
primary_image_tag TEXT,
backdrop_image_tag TEXT,
-- Audio-specific
album_id TEXT,
album_name TEXT,
album_artist TEXT,
artists TEXT, -- JSON array
-- Series/Season-specific
index_number INTEGER,
parent_index_number INTEGER,
series_id TEXT,
series_name TEXT,
season_id TEXT,
-- Additional
genres TEXT, -- JSON array
premiere_date TEXT,
metadata_json TEXT,
-- Sync tracking
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_sync DATETIME,
UNIQUE(jellyfin_id, server_id)
);
-- Performance indexes
CREATE INDEX idx_items_server ON items(server_id);
CREATE INDEX idx_items_library ON items(library_id);
CREATE INDEX idx_items_parent ON items(parent_id);
CREATE INDEX idx_items_type ON items(type);
CREATE INDEX idx_items_album ON items(album_id);
CREATE INDEX idx_items_series ON items(series_id);
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
-- Full-text search
CREATE VIRTUAL TABLE items_fts USING fts5(
name,
overview,
artists,
album_name,
album_artist,
content='items',
content_rowid='id'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
END;
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
END;
```
### media_streams
Stores subtitle and audio track information for items.
```sql
CREATE TABLE media_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
stream_index INTEGER NOT NULL,
type TEXT NOT NULL,
codec TEXT,
language TEXT,
display_title TEXT,
is_default BOOLEAN DEFAULT 0,
is_forced BOOLEAN DEFAULT 0,
is_external BOOLEAN DEFAULT 0,
path TEXT,
UNIQUE(item_id, stream_index)
);
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
```
### user_data
Stores per-user data for items (favorites, progress, play count).
```sql
CREATE TABLE user_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Playback state
position_ticks INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
played BOOLEAN DEFAULT 0,
last_played DATETIME,
-- User preferences
is_favorite BOOLEAN DEFAULT 0,
user_rating REAL,
-- Sync tracking
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME,
needs_sync BOOLEAN DEFAULT 0,
UNIQUE(item_id, user_id)
);
CREATE INDEX idx_user_data_item ON user_data(item_id);
CREATE INDEX idx_user_data_user ON user_data(user_id);
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
WHERE position_ticks > 0 AND played = 0;
```
### downloads
Tracks downloaded media files.
```sql
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT,
file_size INTEGER,
file_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending',
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
transcode_profile TEXT,
priority INTEGER DEFAULT 0,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
completed_at DATETIME,
expires_at DATETIME,
UNIQUE(item_id, user_id)
);
CREATE INDEX idx_downloads_status ON downloads(status);
CREATE INDEX idx_downloads_user ON downloads(user_id);
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
WHERE status IN ('pending', 'downloading');
```
### sync_queue
Stores mutations to sync back to server when online.
```sql
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
operation TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT,
status TEXT DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 5,
last_attempt DATETIME,
error_message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
WHERE status = 'pending';
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
```
### thumbnails
Caches downloaded artwork.
```sql
CREATE TABLE thumbnails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
image_type TEXT NOT NULL,
image_tag TEXT,
file_path TEXT NOT NULL,
width INTEGER,
height INTEGER,
file_size INTEGER,
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(item_id, image_type, width)
);
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
```
### playlists (for local/synced playlists)
```sql
CREATE TABLE playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jellyfin_id TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
is_local_only BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
synced_at DATETIME,
needs_sync BOOLEAN DEFAULT 0
);
CREATE TABLE playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(playlist_id, item_id)
);
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
```
## Key Queries
### Get items for offline library browsing
```sql
-- Get all albums in a music library
SELECT * FROM items
WHERE library_id = ? AND type = 'MusicAlbum'
ORDER BY sort_name;
-- Get tracks for an album
SELECT * FROM items
WHERE album_id = ? AND type = 'Audio'
ORDER BY parent_index_number, index_number;
```
### Resume / Continue Watching
```sql
SELECT i.*, ud.position_ticks, ud.last_played
FROM items i
JOIN user_data ud ON ud.item_id = i.id
WHERE ud.user_id = ?
AND ud.position_ticks > 0
AND ud.played = 0
ORDER BY ud.last_played DESC
LIMIT 20;
```
### Offline search
```sql
SELECT i.* FROM items i
JOIN items_fts fts ON fts.rowid = i.id
WHERE items_fts MATCH ?
ORDER BY rank;
```
### Download queue management
```sql
-- Get next item to download
SELECT d.*, i.name, i.type
FROM downloads d
JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending'
ORDER BY d.priority DESC, d.created_at ASC
LIMIT 1;
-- Get download progress for UI
SELECT
d.status,
COUNT(*) as count,
SUM(d.file_size) as total_size,
SUM(d.bytes_downloaded) as downloaded
FROM downloads d
WHERE d.user_id = ?
GROUP BY d.status;
```
### Sync queue processing
```sql
-- Get pending sync operations (oldest first)
SELECT * FROM sync_queue
WHERE status = 'pending'
AND attempts < max_attempts
ORDER BY created_at ASC
LIMIT 10;
-- Mark operation complete
UPDATE sync_queue
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
WHERE id = ?;
```
## Data Flow
### Online Mode
```mermaid
flowchart TB
subgraph OnlineMode["Online Mode"]
JellyfinServer["Jellyfin Server"]
OnlineRepo["OnlineRepo"]
SQLite["SQLite"]
HybridRepo["HybridRepository"]
UI["UI / Stores"]
JellyfinServer -->|"API Response"| OnlineRepo
OnlineRepo -->|"Cache"| SQLite
SQLite -->|"Sync"| JellyfinServer
OnlineRepo -->|"Response"| HybridRepo
SQLite -->|"Fallback"| HybridRepo
HybridRepo --> UI
end
```
### Offline Mode
```mermaid
flowchart TB
subgraph OfflineMode["Offline Mode"]
OfflineRepo["OfflineRepo"]
SQLite2["SQLite"]
SyncQueue["sync_queue<br/>(Queued for later)"]
HybridRepo2["HybridRepository"]
UI2["UI / Stores"]
OfflineRepo <-->|"Query"| SQLite2
SQLite2 -->|"Mutations"| SyncQueue
OfflineRepo --> HybridRepo2
HybridRepo2 --> UI2
end
```
### Sync on Reconnect
```mermaid
flowchart LR
NetworkRestored["Network restored"]
SyncService["SyncService"]
SyncQueue2["sync_queue"]
JellyfinAPI["Jellyfin API"]
MarkSynced["Mark synced"]
NetworkRestored --> SyncService
SyncService -->|"Read"| SyncQueue2
SyncQueue2 -->|"Send"| JellyfinAPI
JellyfinAPI -->|"Success"| MarkSynced
MarkSynced --> SyncService
```
## Storage Estimates
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|--------------|---------------|----------------|------------|
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
| Movie | ~5 KB | ~200 KB | 1-8 GB |
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
## Rust Module Structure
```
src-tauri/src/storage/
├── mod.rs # Module exports, Database struct
├── schema.rs # Table definitions, migrations
├── models.rs # Rust structs matching tables
├── queries/
│ ├── mod.rs
│ ├── items.rs # Item CRUD operations
│ ├── user_data.rs # User data operations
│ ├── downloads.rs # Download queue operations
│ └── sync.rs # Sync queue operations
└── sync/
├── mod.rs # SyncService
├── manager.rs # Background sync manager
└── operations.rs # Individual sync operation handlers
```
+69
View File
@@ -0,0 +1,69 @@
# Security
## Authentication Token Storage
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
```mermaid
flowchart TB
LoginSuccess["Login Success"]
KeyringCheck{"System Keyring<br/>Available?"}
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
LoginSuccess --> KeyringCheck
KeyringCheck -->|"Yes"| OSCredential
KeyringCheck -->|"No"| EncryptedFallback
```
**Key Format:**
```
jellytau::{server_id}::{user_id}::access_token
```
**Rationale:**
- Tokens in SQLite would be readable if the database file is accessed
- System keyrings provide OS-level encryption and access control
- Fallback ensures functionality on minimal systems without a keyring daemon
## Secure Storage Module
**Location**: `src-tauri/src/secure_storage/` (planned)
```rust
pub trait SecureStorage: Send + Sync {
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
}
// Platform implementations
pub struct KeyringStorage; // Uses keyring crate
pub struct EncryptedFileStorage; // AES-256-GCM fallback
```
## Network Security
| Aspect | Implementation |
|--------|----------------|
| Transport | HTTPS required for all Jellyfin API calls |
| Certificate Validation | System CA store (configurable for self-signed) |
| Token Transmission | Bearer token in `Authorization` header only |
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
## Local Data Protection
| Data Type | Protection |
|-----------|------------|
| Access Tokens | System keyring or encrypted file |
| Database (SQLite) | Plaintext (metadata only, no secrets) |
| Downloaded Media | Filesystem permissions only |
| Cached Thumbnails | Filesystem permissions only |
## Security Considerations
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
2. **Token Isolation**: Each user/server combination has a separate token entry
3. **Logout Cleanup**: Token deletion from secure storage on logout
4. **No Token Logging**: Tokens are never written to logs or debug output
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
+206
View File
@@ -0,0 +1,206 @@
# JellyTau Software Architecture
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
**Last Updated:** 2026-06-20
## Architecture Overview
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
### Architecture Principles
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
- **Poison-tolerant locking**: Shared `std::sync` state is accessed 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 (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
```mermaid
flowchart TB
subgraph Frontend["Svelte Frontend"]
subgraph Stores["Stores (Thin Wrappers)"]
auth["auth"]
player["player"]
queue["queue"]
library["library"]
connectivity["connectivity"]
playbackMode["playbackMode"]
end
subgraph Components
playerComp["player/"]
libraryComp["library/"]
Search["Search"]
end
subgraph Routes
routeLibrary["/library"]
routePlayer["/player"]
routeRoot["/"]
end
subgraph API["API Layer (Thin Client)"]
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
JellyfinClient["JellyfinClient<br/>(Helper)"]
end
end
Frontend -->|"Tauri IPC (invoke)"| Backend
subgraph Backend["Rust Backend (Business Logic)"]
subgraph Commands["Tauri Commands (90+)"]
PlayerCmds["player.rs"]
RepoCmds["repository.rs (27)"]
PlaybackModeCmds["playback_mode.rs (5)"]
StorageCmds["storage.rs"]
ConnectivityCmds["connectivity.rs (7)"]
end
subgraph Core["Core Modules"]
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
end
subgraph Storage["Storage Layer"]
DatabaseService["DatabaseService<br/>(Async trait)"]
SQLite["SQLite Database<br/>(13 tables)"]
end
Commands --> Core
Core --> Storage
Repository --> HttpClient
Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end
```
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
---
## Detailed Documentation
Each major subsystem is documented in its own file in this directory:
| Document | Contents |
|----------|----------|
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
---
## File Structure Summary
```
src-tauri/src/
├── lib.rs # Tauri app setup, state initialization
├── commands/ # Tauri command handlers (90+ commands)
│ ├── mod.rs # Command exports
│ ├── player.rs # 16 player commands
│ ├── repository.rs # 27 repository commands
│ ├── playlist.rs # 7 playlist commands
│ ├── playback_mode.rs # 5 playback mode commands
│ ├── connectivity.rs # 7 connectivity commands
│ ├── storage.rs # Storage & database commands
│ ├── download.rs # 7 download commands
│ ├── offline.rs # 3 offline commands
│ └── sync.rs # Sync queue commands
├── repository/ # Repository pattern implementation
│ ├── mod.rs # MediaRepository trait, handle management
│ ├── types.rs # RepoError, Library, MediaItem, etc.
│ ├── hybrid.rs # HybridRepository with cache-first racing
│ ├── online.rs # OnlineRepository (HTTP API)
│ └── offline.rs # OfflineRepository (SQLite queries)
├── playback_mode/ # Playback mode manager
│ └── mod.rs # PlaybackMode enum, transfer logic
├── connectivity/ # Connectivity monitoring
│ └── mod.rs # ConnectivityMonitor, adaptive polling
├── jellyfin/ # Jellyfin API client
│ ├── mod.rs # Module exports
│ ├── http_client.rs # HTTP client with retry logic
│ └── client.rs # JellyfinClient for API calls
├── storage/ # Database layer
│ ├── mod.rs # Database struct, migrations
│ ├── db_service.rs # DatabaseService trait (async wrapper)
│ ├── schema.rs # Table definitions
│ └── queries/ # Query modules
├── download/ # Download manager module
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
│ ├── events.rs # DownloadEvent enum
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
└── player/ # Player subsystem
├── mod.rs # PlayerController
├── session.rs # MediaSessionManager, MediaSessionType
├── state.rs # PlayerState, PlayerEvent
├── media.rs # MediaItem, MediaSource, MediaType
├── queue.rs # QueueManager, RepeatMode
├── backend.rs # PlayerBackend trait, NullBackend
├── events.rs # PlayerStatusEvent, TauriEventEmitter
├── mpv/ # Linux MPV backend
│ ├── mod.rs # MpvBackend implementation
│ └── event_loop.rs # Dedicated thread for MPV operations
└── android/ # Android ExoPlayer backend
└── mod.rs # ExoPlayerBackend + JNI bindings
src/lib/
├── api/ # Thin API layer (~200 lines total)
│ ├── types.ts # TypeScript type definitions
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
│ ├── client.ts # JellyfinClient (helper for streaming)
│ └── sessions.ts # SessionsApi (remote session control)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
├── stores/ # Thin reactive wrappers over Rust commands
│ ├── index.ts # Re-exports
│ ├── auth.ts # Auth store (calls Rust commands)
│ ├── player.ts # Player store
│ ├── queue.ts # Queue store
│ ├── library.ts # Library store
│ ├── playbackMode.ts # Playback mode store (~150 lines)
│ ├── connectivity.ts # Connectivity store (~250 lines)
│ └── downloads.ts # Downloads store with event listeners
└── components/
├── Search.svelte
├── player/ # Player UI components
├── playlist/ # Playlist modals (Create, AddTo)
├── sessions/ # Remote session control UI
├── downloads/ # Download UI components
└── library/ # Library UI components + PlaylistDetailView
```
## Key Architecture Changes
**What moved to Rust (~3,500 lines of business logic):**
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
- Components + routes (~14.6k lines) — UI and presentation
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
**Total Commands:** 90+ Tauri commands across 14 command modules
+2 -2
View File
@@ -13,7 +13,7 @@ The setup includes:
### Quick Start
**For CI/CD (Gitea Actions)**:
1. Build and push builder image (see [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md))
1. Build and push builder image (see [build-builder-image.md](build-builder-image.md))
2. Push to master branch - workflow runs automatically
3. Check Actions tab for results and APK artifacts
@@ -133,7 +133,7 @@ docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-build
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
See [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md) for detailed instructions.
See [build-builder-image.md](build-builder-image.md) for detailed instructions.
### Setting Up Gitea Act
+300
View File
@@ -0,0 +1,300 @@
# Release Checklist
Quick reference for creating a JellyTau release.
## Pre-Release (1-2 days before)
- [ ] Code is on `master`/`main` branch
- [ ] All feature branches are merged and tested
- [ ] No failing tests locally: `bun run test` and `bun run test:rust`
- [ ] Requirement traceability check passes: `bun run traces:json`
- [ ] Type checking passes: `bun run check`
## Update Version (Day before)
- [ ] Decide on version number (semantic versioning)
- Example: `v1.2.0` (major.minor.patch)
- Example: `v1.0.0-rc1` (release candidate)
- Example: `v1.0.0-beta` (beta)
- [ ] Update version in files:
```bash
# Check these files for version numbers
cat package.json | grep version
cat src-tauri/tauri.conf.json | grep version
cat src-tauri/Cargo.toml | grep version
```
- [ ] Update `CHANGELOG.md`:
- [ ] Add section for new version
- [ ] List all features added
- [ ] List all bugs fixed
- [ ] List breaking changes (if any)
- [ ] Add upgrade instructions (if needed)
- [ ] Format: Markdown with clear sections
- [ ] Update `README.md`:
- [ ] Update any version references
- [ ] Update feature list if applicable
- [ ] Update requirements if changed
- [ ] Commit changes:
```bash
git add .
git commit -m "Bump version to v1.2.0"
git push origin master
```
## Final Check Before Release
- [ ] Run full test suite:
```bash
bun run test # Frontend tests
bun run test:rust # Rust tests
bun run check # Type checking
```
- [ ] Build locally (optional but recommended):
```bash
# Test Linux build
bun run tauri build
# Test Android build
bun run tauri android build
```
- [ ] No uncommitted changes:
```bash
git status # Should show clean working directory
```
## Release (Tag & Push)
```bash
# 1. Create annotated tag with release notes
git tag -a v1.2.0 -m "Release version 1.2.0
Features:
- New feature 1
- New feature 2
Fixes:
- Fixed bug 1
- Fixed bug 2
Improvements:
- Performance improvement 1
- UI improvement 1
Breaking Changes:
- None (or list if applicable)
Migration:
- No action required (or include steps if applicable)"
# 2. Push tag to trigger workflow
git push origin v1.2.0
# 3. Monitor in Gitea Actions
# Go to Actions tab and watch the workflow run
```
## During Release (While Workflow Runs)
- [ ] Watch workflow progress in Gitea Actions
- [ ] Monitor for test failures
- [ ] Monitor for build failures
- [ ] Check build logs if any step fails
## After Release (Workflow Complete)
- [ ] Download artifacts from release page:
- [ ] `jellytau_*.AppImage` (Linux)
- [ ] `jellytau_*.deb` (Linux)
- [ ] `jellytau-release.apk` (Android)
- [ ] `jellytau-release.aab` (Android)
- [ ] Basic testing of artifacts:
- [ ] Linux AppImage runs
- [ ] Linux DEB installs and runs
- [ ] Android APK installs (via `adb` or sideload)
- [ ] Verify release page:
- [ ] Title is correct: "JellyTau vX.Y.Z"
- [ ] Release notes are formatted correctly
- [ ] All artifacts are uploaded
- [ ] Release type is correct (prerelease vs release)
- [ ] Announce release:
- [ ] Post to relevant channels/communities
- [ ] Update website/docs
- [ ] Tag contributors if applicable
## Rollback (If Issues Found)
If critical issues are found after release:
```bash
# Option 1: Delete tag locally and remotely
git tag -d v1.2.0
git push origin :refs/tags/v1.2.0
# Option 2: Mark as prerelease in release page
# Then plan immediate patch release (v1.2.1)
# Option 3: Create hotfix branch and release v1.2.1
git checkout -b hotfix/v1.2.1
# Fix issues
git commit -m "Fix critical issue"
git tag v1.2.1
git push origin hotfix/v1.2.1 v1.2.1
```
## Version Examples
### Major Release
```
v2.0.0 - Major version bump
- Significant new features
- Breaking API changes
- Major UI redesign
```
### Minor Release
```
v1.2.0 - Feature release
- New features
- Backward compatible
- Bug fixes
```
### Patch Release
```
v1.1.1 - Bug fix/patch
- Bug fixes only
- No new features
- Backward compatible
```
### Pre-releases
```
v1.2.0-alpha - Early development
v1.2.0-beta - Late development, feature complete
v1.2.0-rc1 - Release candidate, minimal fixes only
```
## File Locations
Key files for versioning:
- `package.json` - Frontend version
- `src-tauri/tauri.conf.json` - Tauri config version
- `src-tauri/Cargo.toml` - Rust version
- `CHANGELOG.md` - Release history
- `README.md` - Project documentation
## Troubleshooting
### Tests Fail Before Release
1. Don't push tag yet
2. Fix failing tests locally
3. Push fixes to master
4. Re-run test suite
5. Then tag and push
### Build Fails in CI
1. Check detailed logs in Gitea Actions
2. Fix issue locally
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
4. Push fix to master
5. Create new tag with fix
### Release Already Exists
1. If workflow runs twice, artifacts may conflict
2. Check release page
3. If duplicates exist, delete and re-release
### Artifacts Missing
1. Check build logs for errors
2. Verify platform-specific dependencies
3. Delete tag and retry after fixes
## Performance Tips
- Tests: ~5-10 minutes
- Linux build: ~10-15 minutes
- Android build: ~15-20 minutes
- Total release time: ~30-45 minutes
First build takes longer (cache warming). Subsequent releases are faster due to caching.
## Template: Release Notes
```
## 🎉 JellyTau vX.Y.Z
### ✨ Features
- New feature 1
- New feature 2
### 🐛 Bug Fixes
- Fixed issue #123
- Fixed issue #456
### 🚀 Performance
- Improvement 1
- Improvement 2
### 📱 Downloads
- [Linux AppImage](#) - Run on any Linux
- [Linux DEB](#) - Install on Ubuntu/Debian
- [Android APK](#) - Install on Android devices
- [Android AAB](#) - For Google Play Store
### 📋 Requirements
**Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+
### 🔗 Links
- [Changelog](../../CHANGELOG.md)
- [Issues](../../issues)
- [Discussion](../../discussions)
---
Built with Tauri, SvelteKit, and Rust 🦀
```
## Quick Commands
```bash
# View existing tags
git tag -l
# Create release locally (dry run)
git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
# List commits since last tag
git log v1.1.0..HEAD --oneline
# Show tag details
git show v1.2.0
# Rename tag (if needed)
git tag v1.2.0_old v1.2.0
git tag -d v1.2.0
git push origin v1.2.0_old v1.2.0
# Delete tag locally and remotely
git tag -d v1.2.0
git push origin :refs/tags/v1.2.0
```
---
**Tips:**
- ✅ Always test locally before release
- ✅ Use semantic versioning consistently
- ✅ Document changes in CHANGELOG
- ✅ Wait for full workflow completion
- ✅ Test release artifacts before announcing
**Remember:** A good release is a tested release! 🚀
+454
View File
@@ -0,0 +1,454 @@
# Requirements Specification
This document captures JellyTau's user requirements, software requirements,
traceability matrix, test traceability, and known technical debt.
For a narrative overview of the system design, see
[docs/architecture/](architecture/). For development workflows, see the
[README](../README.md) and [scripts/README.md](../scripts/README.md).
## 1. User Requirements
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
| UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
| UR-020 | Select subtitles for video content | High | Done |
| UR-021 | Select audio track for video content | High | Done |
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Planned |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
| 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 |
---
## 2. Software Requirements
### 2.1 Integration Requirements
External system integrations and platform-specific implementations.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| 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 |
### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration.
| ID | Requirement | Endpoint Category | Traces To | Status |
|----|-------------|-------------------|-----------|--------|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
| JA-003 | Get user library views | UserViews | UR-007 | Done |
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
| JA-006 | Search across libraries | Items | UR-008 | Done |
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
| JA-010 | Report playback start | Sessions | UR-025 | Done |
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
| JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
| 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 |
### 2.3 Development Requirements
Internal architecture, components, and application logic.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
| 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 |
---
## 3. Traceability Matrix
### User Requirements to Software Requirements
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| 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-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
| UR-029 | - | DR-032 |
| UR-030 | IR-010 | DR-033 |
| UR-031 | - | DR-034 |
| UR-032 | - | DR-035 |
| UR-033 | - | DR-036 |
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
| UR-036 | IR-022, IR-023 | DR-041 |
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
---
## 4. Test Traceability
### Unit Tests to Software Requirements
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| UT-001 | Player state transitions | DR-001 | Pending |
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
| UT-010 | Download queue management | DR-015 | Done |
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
| UT-016 | All database tables created | IR-013, DR-012 | Done |
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
| UT-026 | NullBackend volume default value | DR-004 | Done |
| UT-027 | NullBackend set volume | DR-004 | Done |
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
| UT-035 | Download event completed serialization | DR-015 | Done |
| UT-036 | Download event failed serialization | DR-015 | Done |
| UT-037 | Download worker exponential backoff | DR-015 | Done |
| UT-038 | Download worker error retryable check | DR-015 | Done |
| UT-039 | Download manager creation | DR-015 | Done |
| UT-040 | Download manager set max concurrent | DR-015 | Done |
| UT-041 | Download info serialization | DR-015 | Done |
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
| UT-044 | Offline item serialization | DR-017 | Done |
| UT-045 | Smart cache default config | DR-015 | Done |
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | DR-015 | Done |
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
| 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 |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
---
## 5. Technical Debt
### Linux Keyring Integration Workaround
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
**Symptoms**:
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
- Session restoration fails on app restart even though credentials exist
**Root Cause**:
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
**Current Workaround**:
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
- **Save**: `secret-tool store --label <label> service <service> username <username>`
- **Retrieve**: `secret-tool lookup service <service> username <username>`
- **Delete**: `secret-tool clear service <service> username <username>`
**Implementation**:
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
Linux-specific `secret-tool` save/get/delete paths.
**Future Fix**:
- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
**Impact**:
- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
**Dependencies**:
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default
---
### Platform Playback Backend Parity (Linux vs Android)
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
**Affected Files**:
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
**Feature Parity Matrix**:
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|---------|-------------|---------------------|--------|
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | ✅ | ❌ | Gap |
| Gapless playback | ✅ | ❌ | Gap |
| Volume normalization | ✅ | ❌ | Gap |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
---
### Frontend Playback Code Duplication
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
**Symptoms**:
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
**Duplicated Code**:
```typescript
// These handlers are identical in both AudioPlayer and MiniPlayer:
handlePlayPause(), handleNext(), handlePrevious(),
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
// These derived states use identical logic:
displayMedia, displayIsPlaying, displayPosition, displayDuration
```
**Future Fix**:
1. Create `src/lib/utils/playbackUnits.ts`:
```typescript
export const TICKS_PER_SECOND = 10_000_000;
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
```
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
- Single source of truth for merged local/remote state
3. Simplify handler wrappers using a utility:
```typescript
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
```
**Impact**:
- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time
**Traces To**: DR-009
@@ -138,7 +138,7 @@ The workflow **warns** (but doesn't block) if:
3. Download **traceability-reports** artifact
4. View:
- `traces-report.json` - Raw trace data
- `docs/TRACEABILITY.md` - Formatted report
- `docs/traceability.md` - Formatted report
### Locally
```bash
@@ -147,7 +147,7 @@ bun run traces:json | jq '.byType'
# Generate full report
bun run traces:markdown
cat docs/TRACEABILITY.md
cat docs/traceability.md
```
## Coverage Goals
@@ -272,7 +272,7 @@ fi
- [Extract Traces Script](../scripts/README.md#extract-tracests)
- [Requirements Specification](../README.md#requirements-specification)
- [Traceability Matrix](./TRACEABILITY.md)
- [Traceability Matrix](./traceability.md)
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
## Support
File diff suppressed because it is too large Load Diff
+212
View File
@@ -0,0 +1,212 @@
# TRACES Quick Reference Guide
## What are TRACES?
TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.
Format: `// TRACES: UR-001, UR-002 | DR-003`
## Quick Examples
### TypeScript
```typescript
// TRACES: UR-005, UR-026 | DR-029
export function handlePlayback() { }
/**
* Resume playback from saved position
* TRACES: UR-019 | DR-022
*/
export async function resumePlayback(itemId: string) { }
```
### Svelte
```svelte
<!-- TRACES: UR-007, UR-008 | DR-007 -->
<script>
export let items = [];
</script>
```
### Rust
```rust
/// TRACES: UR-005 | DR-001
pub enum PlayerState { ... }
#[test]
fn test_queue_next() {
// TRACES: UR-005 | DR-005 | UT-003
}
```
## Requirement Types
| Type | Meaning | Example |
|------|---------|---------|
| **UR** | User Requirement | UR-005: Control media playback |
| **IR** | Integration Requirement | IR-003: LibMPV integration |
| **DR** | Development Requirement | DR-001: Player state machine |
| **JA** | Jellyfin API Requirement | JA-007: Get playback info |
| **UT** | Unit Test | UT-001: Player state transitions |
| **IT** | Integration Test | IT-003: Audio playback via libmpv |
## Where to Find Requirements
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
## How to Add TRACES
### Step 1: Find the Requirement
Look up the requirement in README.md or the traceability matrix.
Example: `UR-005: Control media playback (pause, play, skip, scrub)`
### Step 2: Add Comment
Add TRACES comment at the top of the function/type/module:
```typescript
// TRACES: UR-005
export async function playMedia(itemId: string) {
// Implementation
}
```
### Step 3: Run Extraction
Verify the trace is captured:
```bash
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
```
## Common Patterns
### Single Requirement
```typescript
// TRACES: UR-005
function handlePlay() { }
```
### Multiple Requirements, Same Type
```typescript
// TRACES: UR-005, UR-026, UR-019
function handlePlaybackState() { }
```
### Multiple Types
```typescript
// TRACES: UR-005, UR-026 | DR-029
function autoplayNextEpisode() { }
```
### Test Coverage
```typescript
// TRACES: UR-005 | UT-001
#[test]
fn test_player_state_transition() { }
```
### Modules/Files
```typescript
/**
* Player event handling
* TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
*/
```
## Validation
### Check Your Changes
```bash
# View current coverage
bun run traces:json | jq '.byType'
# Generate full report
bun run traces:markdown
# Check specific requirement
bun run traces:json | jq '.requirements."UR-005"'
```
### Before Committing
1. Ensure all new code has TRACES
2. Format is correct: `// TRACES: ...`
3. Requirements exist in README.md
4. No typos in requirement IDs
## CI/CD Validation
The workflow automatically checks:
- ✅ Coverage stays >= 50%
- ✅ New files have TRACES
- ✅ JSON format is valid
- ✅ Reports are generated
See [traceability-ci.md](docs/traceability-ci.md) for details.
## Tips & Tricks
### Find Related Code
```bash
# Find all code tracing to UR-005
bun run traces:json | jq '.requirements."UR-005"'
# List all tests
bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
```
### Update Your Editor
**VS Code:**
```json
{
"editor.wordBasedSuggestions": false,
"editor.suggest.custom": [
{
"name": "TRACES Format",
"insertText": "// TRACES: $1",
"insertTextRules": "InsertAsSnippet"
}
]
}
```
### Find Untraced Code
```bash
# Files modified without TRACES
git diff --name-only | xargs grep -L "TRACES:" | head -10
```
## FAQ
**Q: Do I need TRACES on every function?**
A: Only for code that implements requirements. Internal helpers don't need TRACES.
**Q: Can I use TRACES on multiple related functions?**
A: Yes! Add at the file/module level or on individual functions.
**Q: What if code doesn't relate to any requirement?**
A: Leave it untraced. TRACES are for requirement-driven development.
**Q: How often should I regenerate reports?**
A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`
**Q: Can I trace to requirements that aren't implemented yet?**
A: Yes! TRACES show your implementation plan.
## See Also
- [Full Traceability Matrix](docs/traceability.md)
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
- [Requirements Specification](README.md)
- [Extraction Script](scripts/README.md#extract-tracests)
---
**Quick Start:**
1. Add `// TRACES: UR-XXX` to new code
2. Run `bun run traces:markdown`
3. Check `docs/traceability.md`
4. Submit PR - workflow validates automatically!
View File
-10227
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -3,6 +3,7 @@
"version": "0.1.0",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -18,6 +19,7 @@
"test:rust": "./scripts/test-rust.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",
"android:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh",
@@ -26,7 +28,7 @@
"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"
},
"license": "MIT",
"dependencies": {
@@ -56,7 +58,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"
}
}
+4 -4
View File
@@ -68,7 +68,7 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
```bash
bun run traces # Generate markdown report
bun run traces:json # Generate JSON report
bun run traces:markdown # Save to docs/TRACEABILITY.md
bun run traces:markdown # Save to docs/traceability.md
```
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
@@ -82,7 +82,7 @@ Example TRACES comment in code:
function handlePlayback() { ... }
```
See [docs/TRACEABILITY.md](../docs/TRACEABILITY.md) for the latest generated mapping.
See [docs/traceability.md](../docs/traceability.md) for the latest generated mapping.
### CI/CD Validation
@@ -93,8 +93,8 @@ The traceability system is integrated with Gitea Actions CI/CD:
- Generates traceability reports automatically
For details, see:
- [Traceability CI Guide](../docs/TRACEABILITY_CI.md) - Full CI/CD documentation
- [TRACES Quick Reference](../TRACES_QUICK_REF.md) - Quick guide for adding TRACES
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
## Utility Scripts
+5
View File
@@ -18,6 +18,11 @@ echo ""
# Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}"
# Step 0: Clear build caches to ensure fresh builds
echo "🧹 Clearing build caches..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1
# Step 1: Sync Android source files
echo "🔄 Syncing Android sources..."
./scripts/sync-android-sources.sh
+7 -3
View File
@@ -5,7 +5,7 @@
* Usage:
* bun run scripts/extract-traces.ts
* bun run scripts/extract-traces.ts --format json
* bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
*/
import * as fs from "fs";
@@ -36,6 +36,10 @@ interface TracesData {
};
}
// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
const BASE_DIR = path.resolve(import.meta.dir, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
@@ -45,7 +49,7 @@ function extractRequirementIds(tracesString: string): string[] {
}
function getAllSourceFiles(): string[] {
const baseDir = "/home/dtourolle/Development/JellyTau";
const baseDir = BASE_DIR;
const patterns = ["src", "src-tauri/src"];
const files: string[] = [];
@@ -101,7 +105,7 @@ function extractTraces(): TracesData {
};
let totalTraces = 0;
const baseDir = "/home/dtourolle/Development/JellyTau";
const baseDir = BASE_DIR;
const files = getAllSourceFiles();
+10
View File
@@ -31,4 +31,14 @@ for kt_file in "$SOURCE_DIR"/*.kt; do
fi
done
# Restore the app module build.gradle.kts (media3 deps + release signing config).
# gen/android is regenerated by `tauri android init`, so this tracked template is
# the source of truth and must be copied back after any (re)generation.
APP_GRADLE_SRC="$PROJECT_ROOT/src-tauri/android/app/build.gradle.kts"
APP_GRADLE_DST="$PROJECT_ROOT/src-tauri/gen/android/app/build.gradle.kts"
if [ -f "$APP_GRADLE_SRC" ]; then
cp "$APP_GRADLE_SRC" "$APP_GRADLE_DST"
echo " Copied: app/build.gradle.kts"
fi
echo "✓ Android sources synced successfully"
+1 -1
View File
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
echo ""
echo "📦 Running frontend tests..."
bun test
bun run test
echo ""
echo "🦀 Running Rust tests..."
+1 -1
View File
@@ -4,4 +4,4 @@
set -e
echo "📦 Running frontend tests..."
bun test "$@"
bun run test "$@"
-38
View File
@@ -1,38 +0,0 @@
[target.aarch64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.armv7-linux-androideabi]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.i686-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.x86_64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[env]
# Point to the NDK for the cc crate and other build scripts
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
# Set CC/CXX for each Android target (cc crate looks for these)
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
+107 -42
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "adler2"
version = "2.0.1"
@@ -1745,7 +1751,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core",
]
[[package]]
@@ -2011,14 +2017,18 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"specta",
"specta-typescript",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-specta",
"tempfile",
"tokio",
"tokio-rusqlite",
"tokio-util",
"urlencoding",
"uuid",
]
@@ -2795,6 +2805,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3933,6 +3949,51 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"chrono",
"paste",
"specta-macros",
"thiserror 1.0.69",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
dependencies = [
"specta",
"thiserror 1.0.69",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
dependencies = [
"specta",
"specta-serde",
"thiserror 1.0.69",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4092,7 +4153,7 @@ dependencies = [
"unicode-segmentation",
"url",
"windows",
"windows-core 0.61.2",
"windows-core",
"windows-version",
"x11-dl",
]
@@ -4149,6 +4210,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@@ -4337,6 +4399,34 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "tauri-utils"
version = "2.8.1"
@@ -4870,6 +4960,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5143,7 +5239,7 @@ dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows-core 0.61.2",
"windows-core",
"windows-implement",
"windows-interface",
]
@@ -5167,7 +5263,7 @@ checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [
"thiserror 2.0.17",
"windows",
"windows-core 0.61.2",
"windows-core",
]
[[package]]
@@ -5223,7 +5319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core 0.61.2",
"windows-core",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
@@ -5235,7 +5331,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
"windows-core",
]
[[package]]
@@ -5247,21 +5343,8 @@ dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
"windows-result",
"windows-strings",
]
[[package]]
@@ -5270,7 +5353,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-core",
"windows-link 0.1.3",
"windows-threading",
]
@@ -5315,7 +5398,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-core",
"windows-link 0.1.3",
]
@@ -5328,15 +5411,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -5346,15 +5420,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -5750,7 +5815,7 @@ dependencies = [
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows-core 0.61.2",
"windows-core",
"windows-version",
"x11-dl",
]
+9
View File
@@ -14,6 +14,11 @@ edition = "2021"
name = "jellytau_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
# Keep debug info minimal to reduce target/ size in CI (line numbers in
# backtraces are preserved; the bulky full debuginfo is dropped).
[profile.dev]
debug = "line-tables-only"
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -28,6 +33,7 @@ rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
urlencoding = "2"
futures-util = "0.3"
async-trait = "0.1"
@@ -45,6 +51,9 @@ sha2 = "0.10"
getrandom = "0.2"
log = "0.4"
env_logger = "0.11"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
+103
View File
@@ -0,0 +1,103 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
// Release signing: loaded from gen/android/keystore.properties if present.
// Falls back to no signing config (debug-signed) when the file is absent.
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.dtourolle.jellytau"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.dtourolle.jellytau"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
signingConfigs {
create("release") {
keystoreProperties.getProperty("storeFile")?.let {
storeFile = file(it)
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
if (keystoreProperties.getProperty("storeFile") != null) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
// Media3 dependencies for audio playback
implementation("androidx.media3:media3-exoplayer:1.5.0")
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
implementation("androidx.media3:media3-session:1.5.0")
implementation("androidx.media3:media3-common:1.5.0")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
implementation("androidx.media:media:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
@@ -0,0 +1,86 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import com.dtourolle.jellytau.player.JellyTauPlayer
/**
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
*
* This class handles attaching and detaching the native ExoPlayer SurfaceView
* so that it renders video content behind the WebView.
*/
object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null
/**
* Attach the video SurfaceView to the Activity's content view.
*
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
*
* @param activity The Activity to attach the surface to
*/
fun attachVideoSurface(activity: Activity) {
try {
// Get the SurfaceView from JellyTauPlayer
val player = JellyTauPlayer.getInstance()
val surfaceView = player.getSurfaceView()
if (surfaceView == null) {
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
return
}
// Get the root content view
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
// Remove from parent if already attached elsewhere
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
// Configure layout params to fill the screen
val layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Add to content view at index 0 (behind WebView)
contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
} catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
}
}
/**
* Detach the video SurfaceView from the Activity's view hierarchy.
*
* @param activity The Activity to detach the surface from
*/
fun detachVideoSurface(activity: Activity) {
try {
attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView)
attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
}
} catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
}
}
/**
* Check if a video surface is currently attached.
*
* @return true if a surface is attached, false otherwise
*/
fun isVideoSurfaceAttached(): Boolean {
return attachedSurfaceView != null
}
}
@@ -0,0 +1,142 @@
package com.dtourolle.jellytau.player
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
/**
* Memory cache for album artwork bitmaps with LRU eviction.
*
* Features:
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
* - Graceful error handling for network failures and corrupted images
* - Singleton pattern for app-wide access
*
* Cache lifecycle: In-memory only, cleared on app termination.
*/
class AlbumArtCache(context: Context) {
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024 // Size in KB
}
}
/**
* Get artwork bitmap for the given URL.
*
* Checks memory cache first, then downloads from network if not cached.
* Scaling and error handling are done transparently.
*
* @param url The Jellyfin server artwork URL
* @return The bitmap, or null if download failed or URL is invalid
*/
suspend fun getArtwork(url: String): Bitmap? {
// Check memory cache first
memoryCache.get(url)?.let { return it }
// Download from network if not cached
return downloadAndCache(url)
}
/**
* Download artwork from network and add to cache.
*
* Runs on IO dispatcher to avoid blocking the main thread.
* Automatically scales large images to 512x512 max.
* On failure, logs error and returns null.
*
* @param url The Jellyfin server artwork URL
* @return The cached bitmap, or null if download/decode failed
*/
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.doInput = true
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
return@withContext null
}
val input = connection.inputStream
val bitmap = BitmapFactory.decodeStream(input)
input.close()
connection.disconnect()
bitmap?.let {
// Scale down if too large (lock screen doesn't need full resolution)
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
memoryCache.put(url, scaled)
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
scaled
}
} catch (e: Exception) {
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
null
}
}
/**
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
*
* @param bitmap The original bitmap
* @param maxSize Maximum width or height (e.g., 512)
* @return Original bitmap if smaller than maxSize, else scaled version
*/
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
val ratio = minOf(
maxSize.toFloat() / bitmap.width,
maxSize.toFloat() / bitmap.height
)
val newWidth = (bitmap.width * ratio).toInt()
val newHeight = (bitmap.height * ratio).toInt()
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
/**
* Clear all cached bitmaps from memory.
*
* Useful for memory-constrained situations or settings reset.
*/
fun clear() {
memoryCache.evictAll()
}
companion object {
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
@Volatile
private var instance: AlbumArtCache? = null
/**
* Get or create singleton instance.
*
* Thread-safe with double-checked locking pattern.
*
* @param context Android context for initialization
* @return The singleton AlbumArtCache instance
*/
fun getInstance(context: Context): AlbumArtCache {
return instance ?: synchronized(this) {
instance ?: AlbumArtCache(context).also { instance = it }
}
}
}
}
@@ -138,11 +138,22 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */
private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */
private var currentTitle: String = ""
private var currentArtist: String = ""
private var currentAlbum: String? = null
private var currentDurationMs: Long = 0
private var currentArtworkUrl: String? = null
private var currentArtworkBitmap: android.graphics.Bitmap? = null
/** Media type enum */
enum class MediaType { AUDIO, VIDEO }
@@ -169,6 +180,12 @@ class JellyTauPlayer(private val appContext: Context) {
// Create ExoPlayer with audio focus handling
exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
// the lockscreen notification in sync automatically.
.setHandleAudioBecomingNoisy(true)
.build()
// Set up player listener
@@ -200,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates()
nativeOnPlaybackEnded()
// Only notify the backend once per loaded media. ExoPlayer
// can re-enter STATE_ENDED, which would double-count things
// like the sleep-timer episode counter.
if (!endedNotified) {
endedNotified = true
nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
}
Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@@ -314,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
@@ -544,12 +570,15 @@ class JellyTauPlayer(private val appContext: Context) {
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates
currentTitle = title
currentArtist = artist ?: ""
currentAlbum = album
currentDurationMs = durationMs
currentArtworkUrl = artworkUrl
currentArtworkBitmap = null // Reset on new track
// Detect media type
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
@@ -666,6 +695,20 @@ class JellyTauPlayer(private val appContext: Context) {
// Start the foreground service for lockscreen controls
startPlaybackService()
// Download album art asynchronously (non-blocking)
currentArtworkUrl?.let { url ->
coroutineScope.launch {
try {
val bitmap = AlbumArtCache.getInstance(appContext).getArtwork(url)
currentArtworkBitmap = bitmap
updatePlaybackServiceNotification(exoPlayer.isPlaying)
android.util.Log.d("JellyTauPlayer", "Album art loaded: ${bitmap?.width}x${bitmap?.height}")
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Failed to load album art", e)
}
}
}
}
}
+40 -38
View File
@@ -10,8 +10,9 @@ use crate::connectivity::ConnectivityMonitor;
pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "AuthServerInfo")]
pub struct ServerInfo {
pub name: String,
pub version: String,
@@ -21,7 +22,7 @@ pub struct ServerInfo {
}
/// User information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
@@ -31,7 +32,7 @@ pub struct User {
}
/// Authentication result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResult {
pub user: User,
@@ -40,7 +41,7 @@ pub struct AuthResult {
}
/// Active session for restoration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub user_id: String,
@@ -55,7 +56,7 @@ pub struct Session {
// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
@@ -63,7 +64,7 @@ struct PublicSystemInfo {
id: String,
}
#[derive(Debug, Deserialize)]
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
@@ -71,7 +72,7 @@ struct AuthenticateByNameResponse {
server_id: String,
}
#[derive(Debug, Deserialize)]
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUser {
id: String,
@@ -102,12 +103,18 @@ impl AuthManager {
self.connectivity_monitor = Some(monitor);
}
/// Normalize and validate server URL
pub fn normalize_url(url: &str) -> String {
/// Normalize and validate server URL.
/// Enforces HTTPS — plain HTTP is rejected for security.
pub fn normalize_url(url: &str) -> Result<String, String> {
let mut normalized = url.trim().to_string();
// Reject plain HTTP — all connections must use HTTPS
if normalized.starts_with("http://") {
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
}
// Add https:// if no protocol specified
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
if !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
@@ -116,12 +123,12 @@ impl AuthManager {
normalized.pop();
}
normalized
Ok(normalized)
}
/// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url);
let normalized_url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/System/Info/Public", normalized_url);
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
@@ -165,7 +172,7 @@ impl AuthManager {
password: &str,
device_id: &str,
) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url);
let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/AuthenticateByName", url);
log::info!("[AuthManager] Authenticating user: {}", username);
@@ -227,7 +234,7 @@ impl AuthManager {
access_token: &str,
device_id: &str,
) -> Result<User, String> {
let url = Self::normalize_url(server_url);
let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Users/{}", url, user_id);
log::info!("[AuthManager] Verifying session for user: {}", user_id);
@@ -290,7 +297,7 @@ impl AuthManager {
access_token: &str,
device_id: &str,
) -> Result<(), String> {
let url = Self::normalize_url(server_url);
let url = Self::normalize_url(server_url)?;
let endpoint = format!("{}/Sessions/Logout", url);
log::info!("[AuthManager] Logging out");
@@ -337,43 +344,43 @@ mod tests {
use super::*;
/// Test URL normalization - adds https:// when missing
///
/// Ensures that URLs without protocol are normalized to https://
/// This prevents "builder error" when constructing HTTP requests.
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com"),
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096"),
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing protocol
/// Test URL normalization - preserves existing https
#[test]
fn test_normalize_url_preserves_protocol() {
fn test_normalize_url_preserves_https() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com"),
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("http://localhost:8096"),
"http://localhost:8096"
);
}
/// Test URL normalization - rejects HTTP
#[test]
fn test_normalize_url_rejects_http() {
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/"),
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/"),
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
"https://jellyfin.example.com"
);
}
@@ -382,25 +389,20 @@ mod tests {
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com "),
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - complex case
///
/// This is the bug that caused the login issue: user enters URL
/// without protocol, it gets stored in DB, then fails when building
/// HTTP requests.
/// Test URL normalization - real world case
#[test]
fn test_normalize_url_real_world_case() {
// User input: "jellyfin.tourolle.paris"
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input);
let normalized = AuthManager::normalize_url(input).unwrap();
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
+213 -6
View File
@@ -12,6 +12,7 @@ pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerif
/// Initialize the auth manager (call on app startup)
/// Restores session from storage if available
#[tauri::command]
#[specta::specta]
pub async fn auth_initialize(
auth_manager: State<'_, AuthManagerWrapper>,
database: State<'_, crate::commands::DatabaseWrapper>,
@@ -39,7 +40,7 @@ pub async fn auth_initialize(
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
let session = Session {
user_id: active_session.user_id,
@@ -61,6 +62,7 @@ pub async fn auth_initialize(
/// Connect to a Jellyfin server and get server info
#[tauri::command]
#[specta::specta]
pub async fn auth_connect_to_server(
server_url: String,
auth_manager: State<'_, AuthManagerWrapper>,
@@ -70,6 +72,7 @@ pub async fn auth_connect_to_server(
/// Login with username and password
#[tauri::command]
#[specta::specta]
pub async fn auth_login(
server_url: String,
username: String,
@@ -80,7 +83,7 @@ pub async fn auth_login(
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);
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
let session = Session {
user_id: result.user.id.clone(),
@@ -100,6 +103,7 @@ pub async fn auth_login(
/// Verify current session
#[tauri::command]
#[specta::specta]
pub async fn auth_verify_session(
server_url: String,
user_id: String,
@@ -118,6 +122,7 @@ pub async fn auth_verify_session(
/// Logout (clear session and call Jellyfin logout endpoint)
#[tauri::command]
#[specta::specta]
pub async fn auth_logout(
server_url: String,
access_token: String,
@@ -143,6 +148,7 @@ pub async fn auth_logout(
/// Get current session
#[tauri::command]
#[specta::specta]
pub async fn auth_get_session(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<Option<Session>, String> {
@@ -151,15 +157,19 @@ pub async fn auth_get_session(
/// Set current session (for restoration from storage)
#[tauri::command]
#[specta::specta]
pub async fn auth_set_session(
session: Option<Session>,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<(), String> {
// Normalize the server URL if session is provided
let normalized_session = session.map(|mut s| {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
s
});
let normalized_session = match session {
Some(mut s) => {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
Some(s)
}
None => None,
};
auth_manager.0.set_session(normalized_session).await;
Ok(())
@@ -167,6 +177,7 @@ pub async fn auth_set_session(
/// Start background session verification
#[tauri::command]
#[specta::specta]
pub async fn auth_start_verification(
device_id: String,
app_handle: tauri::AppHandle,
@@ -195,6 +206,7 @@ pub async fn auth_start_verification(
/// Stop background session verification
#[tauri::command]
#[specta::specta]
pub async fn auth_stop_verification(
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
@@ -209,6 +221,7 @@ pub async fn auth_stop_verification(
/// Re-authenticate with password (when session expired)
#[tauri::command]
#[specta::specta]
pub async fn auth_reauthenticate(
password: String,
device_id: String,
@@ -237,3 +250,197 @@ pub async fn auth_reauthenticate(
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_serialization() {
let session = Session {
user_id: "user-123".to_string(),
username: "john_doe".to_string(),
server_id: "server-456".to_string(),
server_url: "https://jellyfin.example.com".to_string(),
server_name: "My Jellyfin".to_string(),
access_token: "token-789-xyz".to_string(),
verified: true,
needs_reauth: false,
};
// Should serialize successfully
let json = serde_json::to_string(&session);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("user-123"));
assert!(serialized.contains("john_doe"));
assert!(serialized.contains("server-456"));
}
#[test]
fn test_session_deserialization() {
let json = r#"{
"userId": "user-123",
"username": "john_doe",
"serverId": "server-456",
"serverUrl": "https://jellyfin.example.com",
"serverName": "My Jellyfin",
"accessToken": "token-789",
"verified": true,
"needsReauth": false
}"#;
let result: Result<Session, _> = serde_json::from_str(json);
assert!(result.is_ok());
let session = result.unwrap();
assert_eq!(session.user_id, "user-123");
assert_eq!(session.username, "john_doe");
assert_eq!(session.server_id, "server-456");
assert!(session.verified);
assert!(!session.needs_reauth);
}
#[test]
fn test_session_roundtrip() {
let original = Session {
user_id: "user-999".to_string(),
username: "alice".to_string(),
server_id: "server-111".to_string(),
server_url: "https://server.local".to_string(),
server_name: "Home Server".to_string(),
access_token: "very-long-token-string".to_string(),
verified: true,
needs_reauth: false,
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: Session = serde_json::from_str(&json).unwrap();
assert_eq!(original.user_id, deserialized.user_id);
assert_eq!(original.username, deserialized.username);
assert_eq!(original.server_id, deserialized.server_id);
assert_eq!(original.server_url, deserialized.server_url);
assert_eq!(original.access_token, deserialized.access_token);
assert_eq!(original.verified, deserialized.verified);
}
#[test]
fn test_session_clone() {
let session = Session {
user_id: "user-clone".to_string(),
username: "test_user".to_string(),
server_id: "server-clone".to_string(),
server_url: "https://clone.example.com".to_string(),
server_name: "Clone Server".to_string(),
access_token: "clone-token".to_string(),
verified: false,
needs_reauth: true,
};
let cloned = session.clone();
assert_eq!(session.user_id, cloned.user_id);
assert_eq!(session.username, cloned.username);
assert_eq!(session.verified, cloned.verified);
assert_eq!(session.needs_reauth, cloned.needs_reauth);
}
#[test]
fn test_session_unverified() {
let session = Session {
user_id: "user-unverified".to_string(),
username: "newuser".to_string(),
server_id: "server-new".to_string(),
server_url: "https://new.example.com".to_string(),
server_name: "New Server".to_string(),
access_token: "new-token".to_string(),
verified: false,
needs_reauth: true,
};
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("false")); // verified: false
assert!(json.contains("true")); // needs_reauth: true
let deserialized: Session = serde_json::from_str(&json).unwrap();
assert!(!deserialized.verified);
assert!(deserialized.needs_reauth);
}
#[test]
fn test_session_debug() {
let session = Session {
user_id: "user-debug".to_string(),
username: "debug_user".to_string(),
server_id: "server-debug".to_string(),
server_url: "https://debug.example.com".to_string(),
server_name: "Debug Server".to_string(),
access_token: "debug-token".to_string(),
verified: true,
needs_reauth: false,
};
let debug_str = format!("{:?}", session);
assert!(debug_str.contains("user-debug"));
assert!(debug_str.contains("Session"));
}
#[test]
fn test_auth_manager_wrapper_structure() {
// Verify wrapper type exists and has correct structure
assert_eq!(std::mem::size_of::<AuthManagerWrapper>() > 0, true);
}
#[test]
fn test_session_verifier_wrapper_structure() {
// Verify wrapper type exists and has correct structure
assert_eq!(std::mem::size_of::<SessionVerifierWrapper>() > 0, true);
}
#[test]
fn test_session_with_special_characters() {
let session = Session {
user_id: "user-special-éñ".to_string(),
username: "user@example.com".to_string(),
server_id: "server/123".to_string(),
server_url: "https://jellyfin.example.com:8096".to_string(),
server_name: "My Jellyfin (v10.8.0)".to_string(),
access_token: "token+with/special=chars".to_string(),
verified: true,
needs_reauth: false,
};
let json = serde_json::to_string(&session).unwrap();
let deserialized: Session = serde_json::from_str(&json).unwrap();
assert_eq!(session.username, deserialized.username);
assert_eq!(session.server_name, deserialized.server_name);
assert_eq!(session.access_token, deserialized.access_token);
}
#[test]
fn test_session_field_presence() {
let session = Session {
user_id: "u1".to_string(),
username: "user1".to_string(),
server_id: "s1".to_string(),
server_url: "url1".to_string(),
server_name: "name1".to_string(),
access_token: "token1".to_string(),
verified: true,
needs_reauth: false,
};
let json = serde_json::to_string(&session).unwrap();
// Verify camelCase serialization (serde rename_all = "camelCase")
assert!(json.contains("userId"));
assert!(json.contains("username"));
assert!(json.contains("serverId"));
assert!(json.contains("serverUrl"));
assert!(json.contains("serverName"));
assert!(json.contains("accessToken"));
assert!(json.contains("verified"));
assert!(json.contains("needsReauth"));
}
}
+22
View File
@@ -7,6 +7,7 @@ pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMon
/// Check if the server is currently reachable
#[tauri::command]
#[specta::specta]
pub async fn connectivity_check_server(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<bool, String> {
@@ -16,6 +17,7 @@ pub async fn connectivity_check_server(
/// Set the server URL and trigger an immediate check
#[tauri::command]
#[specta::specta]
pub async fn connectivity_set_server_url(
url: String,
state: State<'_, ConnectivityMonitorWrapper>,
@@ -27,6 +29,7 @@ pub async fn connectivity_set_server_url(
/// Get the current connectivity status
#[tauri::command]
#[specta::specta]
pub async fn connectivity_get_status(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<ConnectivityStatus, String> {
@@ -36,6 +39,7 @@ pub async fn connectivity_get_status(
/// Start monitoring connectivity with adaptive polling
#[tauri::command]
#[specta::specta]
pub async fn connectivity_start_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
@@ -46,6 +50,7 @@ pub async fn connectivity_start_monitoring(
/// Stop monitoring connectivity
#[tauri::command]
#[specta::specta]
pub async fn connectivity_stop_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
@@ -56,6 +61,7 @@ pub async fn connectivity_stop_monitoring(
/// Mark the server as reachable (called after successful API calls)
#[tauri::command]
#[specta::specta]
pub async fn connectivity_mark_reachable(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
@@ -66,6 +72,7 @@ pub async fn connectivity_mark_reachable(
/// Mark the server as unreachable (called after failed API calls)
#[tauri::command]
#[specta::specta]
pub async fn connectivity_mark_unreachable(
error: Option<String>,
state: State<'_, ConnectivityMonitorWrapper>,
@@ -74,3 +81,18 @@ pub async fn connectivity_mark_unreachable(
monitor.mark_unreachable(error).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connectivity_monitor_wrapper_structure() {
// Test that wrapper can be created and holds Arc
// We can't instantiate ConnectivityMonitor directly in tests
// due to its dependencies, so we just test the wrapper type structure
// This verifies the wrapper type exists and can hold Arc<Mutex>
assert_eq!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0, true);
}
}
+55
View File
@@ -16,6 +16,7 @@ use crate::utils::conversions::{
/// # Returns
/// Formatted string like "3:45" or "12:09"
#[tauri::command]
#[specta::specta]
pub fn format_time_seconds(seconds: f64) -> String {
format_time(seconds)
}
@@ -32,6 +33,7 @@ pub fn format_time_seconds(seconds: f64) -> String {
/// # Returns
/// Formatted string like "1:23:45" or "3:45"
#[tauri::command]
#[specta::specta]
pub fn format_time_seconds_long(seconds: f64) -> String {
format_time_long(seconds)
}
@@ -44,6 +46,7 @@ pub fn format_time_seconds_long(seconds: f64) -> String {
/// # Returns
/// Time in seconds
#[tauri::command]
#[specta::specta]
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
ticks_to_seconds(ticks)
}
@@ -57,6 +60,7 @@ pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
/// # Returns
/// Progress as percentage (0.0 to 100.0)
#[tauri::command]
#[specta::specta]
pub fn calc_progress(position: f64, duration: f64) -> f64 {
calculate_progress(position, duration)
}
@@ -69,6 +73,57 @@ pub fn calc_progress(position: f64, duration: f64) -> f64 {
/// # Returns
/// Normalized volume (0.0 to 1.0)
#[tauri::command]
#[specta::specta]
pub fn convert_percent_to_volume(percent: f64) -> f64 {
percent_to_volume(percent)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time_seconds() {
assert_eq!(format_time_seconds(0.0), "0:00");
assert_eq!(format_time_seconds(59.0), "0:59");
assert_eq!(format_time_seconds(60.0), "1:00");
assert_eq!(format_time_seconds(125.0), "2:05");
assert_eq!(format_time_seconds(3661.0), "61:01");
}
#[test]
fn test_format_time_seconds_long() {
assert_eq!(format_time_seconds_long(0.0), "0:00");
assert_eq!(format_time_seconds_long(59.0), "0:59");
assert_eq!(format_time_seconds_long(3599.0), "59:59");
assert_eq!(format_time_seconds_long(3600.0), "1:00:00");
assert_eq!(format_time_seconds_long(3661.0), "1:01:01");
assert_eq!(format_time_seconds_long(7384.0), "2:03:04");
}
#[test]
fn test_convert_ticks_to_seconds() {
assert_eq!(convert_ticks_to_seconds(0), 0.0);
assert_eq!(convert_ticks_to_seconds(10_000_000), 1.0);
assert_eq!(convert_ticks_to_seconds(5_000_000), 0.5);
assert_eq!(convert_ticks_to_seconds(60_000_000), 6.0);
}
#[test]
fn test_calc_progress() {
assert_eq!(calc_progress(0.0, 100.0), 0.0);
assert_eq!(calc_progress(50.0, 100.0), 50.0);
assert_eq!(calc_progress(100.0, 100.0), 100.0);
assert_eq!(calc_progress(25.0, 0.0), 0.0); // Invalid duration
assert_eq!(calc_progress(150.0, 100.0), 100.0); // Clamped
}
#[test]
fn test_convert_percent_to_volume() {
assert_eq!(convert_percent_to_volume(0.0), 0.0);
assert_eq!(convert_percent_to_volume(50.0), 0.5);
assert_eq!(convert_percent_to_volume(100.0), 1.0);
assert_eq!(convert_percent_to_volume(150.0), 1.0); // Clamped
assert_eq!(convert_percent_to_volume(-10.0), 0.0); // Clamped
}
}
+2
View File
@@ -23,6 +23,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
///
/// TRACES: UR-009 | DR-011
#[tauri::command]
#[specta::specta]
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -79,6 +80,7 @@ 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> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
//! Pinning commands - protect an item's cached metadata from cache clearing.
use std::sync::Arc;
use tauri::State;
use crate::commands::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Pin an item's metadata (protects from cache clear)
#[tauri::command]
#[specta::specta]
pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE items SET is_pinned = 1 WHERE id = ?",
vec![QueryParam::String(item_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Unpin an item's metadata
#[tauri::command]
#[specta::specta]
pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE items SET is_pinned = 0 WHERE id = ?",
vec![QueryParam::String(item_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// 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> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?",
vec![QueryParam::String(item_id)],
);
let is_pinned: i32 = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?
.unwrap_or(0);
Ok(is_pinned == 1)
}
@@ -0,0 +1,228 @@
//! Smart-cache statistics/config and album recommendation commands.
use std::sync::Arc;
use log::info;
use tauri::State;
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// SmartCache statistics
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct SmartCacheStats {
pub total_size: u64,
pub storage_limit: u64,
pub available_space: u64,
pub items_count: i64,
pub config: crate::download::cache::CacheConfig,
}
/// Get SmartCache statistics
#[tauri::command]
#[specta::specta]
pub async fn get_smart_cache_stats(
db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>,
user_id: String,
) -> Result<SmartCacheStats, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Clone cache to avoid holding lock across async operations
let cache = {
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
guard.clone()
};
let total_size = cache
.get_total_download_size_async(&db_service, &user_id)
.await?;
let config = cache
.get_config()
.ok_or_else(|| "Failed to get cache config".to_string())?;
let storage_limit = config.storage_limit;
let available_space = storage_limit.saturating_sub(total_size);
// Get item count
let count_query = Query::with_params(
"SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'",
vec![QueryParam::String(user_id)],
);
let items_count: i64 = db_service
.query_one(count_query, |row| row.get(0))
.await
.unwrap_or(0);
Ok(SmartCacheStats {
total_size,
storage_limit,
available_space,
items_count,
config,
})
}
/// Update SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn update_smart_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
config: crate::download::cache::CacheConfig,
) -> Result<(), String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
cache.update_config(config);
info!("Updated SmartCache configuration");
Ok(())
}
/// Get SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn get_smart_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
) -> Result<crate::download::cache::CacheConfig, String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
cache
.get_config()
.ok_or_else(|| "Failed to get cache config".to_string())
}
/// Album recommendation info
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
pub struct AlbumRecommendation {
pub album_id: String,
pub album_name: String,
pub tracks_played: usize,
pub total_tracks: usize,
pub should_download: bool,
}
/// Album affinity status info
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AlbumAffinityStatus {
pub album_id: String,
pub unique_tracks_played: usize,
pub threshold: usize,
pub threshold_reached: bool,
}
/// Get album recommendations based on play history
#[tauri::command]
#[specta::specta]
pub async fn get_album_recommendations(
db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>,
user_id: String,
) -> Result<Vec<AlbumRecommendation>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Clone cache to avoid holding lock across async operations
let cache = {
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
guard.clone()
};
// Get all albums that user has played tracks from
let query = Query::with_params(
"SELECT DISTINCT i.album_id, a.name
FROM user_data ud
JOIN items i ON ud.item_id = i.id
JOIN items a ON i.album_id = a.id
WHERE ud.user_id = ?
AND ud.play_count > 0
AND i.item_type = 'Audio'
AND i.album_id IS NOT NULL",
vec![QueryParam::String(user_id.clone())],
);
let albums: Vec<(String, String)> = db_service
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?)))
.await
.unwrap_or_default();
let mut recommendations = Vec::new();
for (album_id, album_name) in albums {
// Check if should cache
let should_download = cache.should_cache_album(&album_id).unwrap_or(false);
// Get track counts
let tracks_query = Query::with_params(
"SELECT
COUNT(*) as total,
COUNT(ud.id) as played
FROM items i
LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ?
WHERE i.album_id = ? AND i.item_type = 'Audio'",
vec![
QueryParam::String(user_id.clone()),
QueryParam::String(album_id.clone()),
],
);
let (total_tracks, tracks_played): (i64, i64) = db_service
.query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await
.unwrap_or((0, 0));
if tracks_played > 0 {
recommendations.push(AlbumRecommendation {
album_id,
album_name,
tracks_played: tracks_played as usize,
total_tracks: total_tracks as usize,
should_download,
});
}
}
// Sort by tracks played (descending)
recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played));
Ok(recommendations)
}
/// Get album affinity status for all tracked albums
/// This shows the SmartCache's internal play history and threshold status
#[tauri::command]
#[specta::specta]
pub fn get_album_affinity_status(
smart_cache: State<'_, SmartCacheWrapper>,
) -> Result<Vec<AlbumAffinityStatus>, String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
// Get the threshold from config
let threshold = cache
.get_config()
.map(|c| c.album_affinity_threshold)
.unwrap_or(3);
// Get all tracked albums with their play counts
let play_history = cache.get_album_play_history();
let mut statuses: Vec<AlbumAffinityStatus> = play_history
.into_iter()
.map(|(album_id, unique_tracks_played)| {
let threshold_reached = unique_tracks_played >= threshold;
AlbumAffinityStatus {
album_id,
unique_tracks_played,
threshold,
threshold_reached,
}
})
.collect();
// Sort by play count (descending)
statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played));
Ok(statuses)
}
+2
View File
@@ -10,6 +10,7 @@ pub mod offline;
pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod playlist;
pub mod repository;
pub mod sessions;
pub mod storage;
@@ -25,6 +26,7 @@ pub use playback_mode::*;
#[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 sessions::*;
pub use storage::*;
+4 -1
View File
@@ -7,7 +7,7 @@ use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(Debug, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineItem {
pub id: String,
@@ -22,6 +22,7 @@ pub struct OfflineItem {
/// Check if an item is available offline
#[tauri::command]
#[specta::specta]
pub async fn offline_is_available(
db: State<'_, DatabaseWrapper>,
item_id: String,
@@ -46,6 +47,7 @@ pub async fn offline_is_available(
/// Get all offline items for a user
#[tauri::command]
#[specta::specta]
pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>,
user_id: String,
@@ -84,6 +86,7 @@ pub async fn offline_get_items(
/// Search offline items
#[tauri::command]
#[specta::specta]
pub async fn offline_search(
db: State<'_, DatabaseWrapper>,
user_id: String,
+141 -4
View File
@@ -8,6 +8,7 @@ pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
/// Get the current playback mode
#[tauri::command]
#[specta::specta]
pub fn playback_mode_get_current(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<PlaybackMode, String> {
@@ -16,6 +17,7 @@ pub fn playback_mode_get_current(
/// Set the playback mode (internal/testing use)
#[tauri::command]
#[specta::specta]
pub fn playback_mode_set(
manager: State<'_, PlaybackModeManagerWrapper>,
mode: PlaybackMode,
@@ -26,6 +28,7 @@ pub fn playback_mode_set(
/// Check if currently transferring between playback modes
#[tauri::command]
#[specta::specta]
pub fn playback_mode_is_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<bool, String> {
@@ -34,15 +37,18 @@ pub fn playback_mode_is_transferring(
/// Transfer playback from local device to a remote Jellyfin session
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
position: Option<f64>,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id,
position
);
manager.0.transfer_to_remote(session_id).await
manager.0.transfer_to_remote(session_id, position).await
}
/// Transfer playback from remote session back to local device
@@ -51,6 +57,7 @@ pub async fn playback_mode_transfer_to_remote(
/// - current_item_id: The Jellyfin item ID currently playing on remote
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_transfer_to_local(
manager: State<'_, PlaybackModeManagerWrapper>,
current_item_id: String,
@@ -69,6 +76,7 @@ pub async fn playback_mode_transfer_to_local(
/// Get remote session status (for polling position/duration)
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_get_remote_status(
manager: State<'_, PlaybackModeManagerWrapper>,
player: State<'_, crate::commands::PlayerStateWrapper>,
@@ -119,7 +127,7 @@ pub async fn playback_mode_get_remote_status(
}
/// Remote session status for UI updates
#[derive(serde::Serialize)]
#[derive(specta::Type, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSessionStatus {
pub position: f64,
@@ -127,3 +135,132 @@ pub struct RemoteSessionStatus {
pub is_playing: bool,
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_playback_mode_serialization() {
// Test Local playback mode
let local_mode = PlaybackMode::Local;
let json = serde_json::to_string(&local_mode);
assert!(json.is_ok());
// Test Remote playback mode
let remote_mode = PlaybackMode::Remote {
session_id: "session-123".to_string(),
};
let json = serde_json::to_string(&remote_mode);
assert!(json.is_ok());
}
#[test]
fn test_remote_session_status_serialization() {
let status = RemoteSessionStatus {
position: 123.45,
duration: Some(600.0),
is_playing: true,
now_playing_item: None,
};
// Should serialize successfully
let json = serde_json::to_string(&status);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("123.45"));
assert!(serialized.contains("600"));
assert!(serialized.contains("true"));
}
#[test]
fn test_remote_session_status_with_no_duration() {
let status = RemoteSessionStatus {
position: 0.0,
duration: None,
is_playing: false,
now_playing_item: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("null") || json.contains("\"duration\":null"));
}
#[test]
fn test_remote_session_status_various_positions() {
let positions = vec![0.0, 30.5, 100.0, 3600.0];
for pos in positions {
let status = RemoteSessionStatus {
position: pos,
duration: Some(7200.0),
is_playing: true,
now_playing_item: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains(&pos.to_string()));
}
}
#[test]
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");
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");
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");
match mode {
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
_ => panic!("Expected Remote mode"),
}
}
#[test]
fn test_play_tracks_context_deserialization() {
use crate::commands::PlayTracksContext;
// 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");
match context {
PlayTracksContext::Search { search_query } => {
assert_eq!(search_query, "test query");
}
_ => panic!("Expected Search context"),
}
// 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");
match context {
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
assert_eq!(playlist_id, "pl-123");
assert_eq!(playlist_name, "My Playlist");
}
_ => panic!("Expected Playlist context"),
}
// 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");
match context {
PlayTracksContext::Custom { label } => {
assert_eq!(label, Some("Custom Queue".to_string()));
}
_ => panic!("Expected Custom context"),
}
}
}
@@ -24,6 +24,7 @@ pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>
/// Initialize playback reporter (called after login)
#[tauri::command]
#[specta::specta]
pub async fn playback_reporter_init(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
db: State<'_, DatabaseWrapper>,
@@ -66,6 +67,7 @@ pub async fn playback_reporter_init(
/// Destroy playback reporter (called on logout)
#[tauri::command]
#[specta::specta]
pub async fn playback_reporter_destroy(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
) -> Result<(), String> {
@@ -76,6 +78,7 @@ pub async fn playback_reporter_destroy(
/// Report playback start
#[tauri::command]
#[specta::specta]
pub async fn playback_report_start(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -110,6 +113,7 @@ pub async fn playback_report_start(
/// Report playback progress
#[tauri::command]
#[specta::specta]
pub async fn playback_report_progress(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -138,6 +142,7 @@ pub async fn playback_report_progress(
/// Report playback stopped
#[tauri::command]
#[specta::specta]
pub async fn playback_report_stopped(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -164,6 +169,7 @@ pub async fn playback_report_stopped(
/// Mark item as played
#[tauri::command]
#[specta::specta]
pub async fn playback_mark_played(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -182,3 +188,216 @@ pub async fn playback_mark_played(
reporter_instance.report(operation, is_online).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_playback_operation_start_creation() {
let operation = PlaybackOperation::Start {
item_id: "item-123".to_string(),
position_ticks: 15_000_000,
context: Some(PlaybackContext {
context_type: "series".to_string(),
context_id: Some("series-456".to_string()),
}),
};
// Verify enum variant can be created and pattern matched
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());
let ctx = context.unwrap();
assert_eq!(ctx.context_type, "series");
assert_eq!(ctx.context_id, Some("series-456".to_string()));
} else {
panic!("Expected Start variant");
}
}
#[test]
fn test_playback_operation_start_without_context() {
let operation = PlaybackOperation::Start {
item_id: "item-789".to_string(),
position_ticks: 5_000_000,
context: None,
};
if let PlaybackOperation::Start { item_id, context, .. } = operation {
assert_eq!(item_id, "item-789");
assert!(context.is_none());
} else {
panic!("Expected Start variant");
}
}
#[test]
fn test_playback_operation_progress_creation() {
let operation = PlaybackOperation::Progress {
item_id: "item-999".to_string(),
position_ticks: 30_000_000,
is_paused: true,
};
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);
} else {
panic!("Expected Progress variant");
}
}
#[test]
fn test_playback_operation_progress_playing() {
let operation = PlaybackOperation::Progress {
item_id: "item-555".to_string(),
position_ticks: 45_000_000,
is_paused: false,
};
if let PlaybackOperation::Progress { is_paused, .. } = operation {
assert!(!is_paused);
} else {
panic!("Expected Progress variant");
}
}
#[test]
fn test_playback_operation_stopped_creation() {
let operation = PlaybackOperation::Stopped {
item_id: "item-111".to_string(),
position_ticks: 120_000_000,
};
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
assert_eq!(item_id, "item-111");
assert_eq!(position_ticks, 120_000_000);
} else {
panic!("Expected Stopped variant");
}
}
#[test]
fn test_playback_operation_mark_played_creation() {
let operation = PlaybackOperation::MarkPlayed {
item_id: "item-222".to_string(),
};
if let PlaybackOperation::MarkPlayed { item_id } = operation {
assert_eq!(item_id, "item-222");
} else {
panic!("Expected MarkPlayed variant");
}
}
#[test]
fn test_playback_context_with_series() {
let context = PlaybackContext {
context_type: "series".to_string(),
context_id: Some("series-789".to_string()),
};
assert_eq!(context.context_type, "series");
assert_eq!(context.context_id, Some("series-789".to_string()));
}
#[test]
fn test_playback_context_without_id() {
let context = PlaybackContext {
context_type: "folder".to_string(),
context_id: None,
};
assert_eq!(context.context_type, "folder");
assert!(context.context_id.is_none());
}
#[test]
fn test_playback_context_clone() {
let context = PlaybackContext {
context_type: "container".to_string(),
context_id: Some("container-123".to_string()),
};
let cloned = context.clone();
assert_eq!(cloned.context_type, "container");
assert_eq!(cloned.context_id, Some("container-123".to_string()));
}
#[test]
fn test_seconds_to_ticks_conversion() {
assert_eq!(seconds_to_ticks(0.0), 0);
assert_eq!(seconds_to_ticks(1.0), 10_000_000);
assert_eq!(seconds_to_ticks(1.5), 15_000_000);
assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
}
#[test]
fn test_playback_reporter_wrapper_structure() {
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true);
}
#[test]
fn test_playback_operation_debug_trait() {
// Verify Debug trait is implemented for operations
let operation = PlaybackOperation::Start {
item_id: "item-1".to_string(),
position_ticks: 0,
context: None,
};
let debug_str = format!("{:?}", operation);
assert!(debug_str.contains("Start"));
assert!(debug_str.contains("item-1"));
}
#[test]
fn test_playback_operation_clone() {
let operation = PlaybackOperation::Progress {
item_id: "item-clone".to_string(),
position_ticks: 50_000_000,
is_paused: true,
};
let cloned = operation.clone();
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
assert_eq!(item_id, "item-clone");
assert!(is_paused);
} else {
panic!("Clone failed to preserve variant");
}
}
#[test]
fn test_playback_operation_all_variants() {
// Test that all operation variants can be created and matched
let start_op = PlaybackOperation::Start {
item_id: "i1".to_string(),
position_ticks: 0,
context: None,
};
assert!(matches!(start_op, PlaybackOperation::Start { .. }));
let progress_op = PlaybackOperation::Progress {
item_id: "i2".to_string(),
position_ticks: 100,
is_paused: false,
};
assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
let stopped_op = PlaybackOperation::Stopped {
item_id: "i3".to_string(),
position_ticks: 200,
};
assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
let played_op = PlaybackOperation::MarkPlayed {
item_id: "i4".to_string(),
};
assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
}
}
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
//! Queue manipulation commands (add / remove / move / skip).
use std::path::PathBuf;
use log::info;
use serde::Deserialize;
use tauri::State;
use super::{
check_for_local_download, create_media_item, get_player_status, get_queue_status,
DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus,
};
use crate::commands::repository::RepositoryManagerWrapper;
use crate::player::{MediaItem, MediaSource, MediaType};
use crate::repository::types::{ImageOptions, ImageType};
use crate::repository::MediaRepository;
/// Request to add items to queue
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddToQueueRequest {
pub items: Vec<PlayItemRequest>,
pub position: String, // "next" or "end"
}
/// Request to add a track by ID - backend fetches metadata
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddTrackByIdRequest {
pub track_id: String,
pub position: String, // "next" or "end"
}
/// Request to add multiple tracks by IDs - backend fetches metadata
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddTracksByIdsRequest {
pub track_ids: Vec<String>,
pub position: String, // "next" or "end"
}
#[tauri::command]
#[specta::specta]
pub async fn player_add_to_queue(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
request: AddToQueueRequest,
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
let position = match request.position.as_str() {
"next" => AddPosition::Next,
_ => AddPosition::End,
};
// Create media items first (without holding any locks during await)
let mut items: Vec<MediaItem> = Vec::new();
for req in request.items {
items.push(create_media_item(req, Some(&db)).await?);
}
// Now add to queue
let controller = player.0.lock().await;
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.add(items, position);
let result = QueueStatus {
items: queue_lock.items().to_vec(),
current_index: queue_lock.current_index(),
shuffle: queue_lock.is_shuffle(),
repeat: queue_lock.repeat_mode(),
has_next: queue_lock.has_next(),
has_previous: queue_lock.has_previous(),
};
drop(queue_lock);
controller.emit_queue_changed();
Ok(result)
}
#[tauri::command]
#[specta::specta]
pub async fn player_remove_from_queue(
player: State<'_, PlayerStateWrapper>,
index: usize,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.remove(index);
let result = QueueStatus {
items: queue_lock.items().to_vec(),
current_index: queue_lock.current_index(),
shuffle: queue_lock.is_shuffle(),
repeat: queue_lock.repeat_mode(),
has_next: queue_lock.has_next(),
has_previous: queue_lock.has_previous(),
};
drop(queue_lock);
controller.emit_queue_changed();
Ok(result)
}
#[tauri::command]
#[specta::specta]
pub async fn player_move_in_queue(
player: State<'_, PlayerStateWrapper>,
from_index: usize,
to_index: usize,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
if !queue_lock.move_item(from_index, to_index) {
return Err("Invalid indices for move operation".to_string());
}
let result = QueueStatus {
items: queue_lock.items().to_vec(),
current_index: queue_lock.current_index(),
shuffle: queue_lock.is_shuffle(),
repeat: queue_lock.repeat_mode(),
has_next: queue_lock.has_next(),
has_previous: queue_lock.has_previous(),
};
drop(queue_lock);
controller.emit_queue_changed();
Ok(result)
}
/// Add a track to queue by ID - backend fetches metadata and constructs URLs
#[tauri::command]
#[specta::specta]
pub async fn player_add_track_by_id(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, RepositoryManagerWrapper>,
repository_handle: String,
request: AddTrackByIdRequest,
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
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)
.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
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
// Check for local download first
let local_path = check_for_local_download(&db, &request.track_id).await?;
let source = if let Some(path) = local_path {
info!("Using local download for track {}", request.track_id);
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(track.id.clone()),
}
} else {
// Get stream URL from repository (works online/offline)
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 {
stream_url,
jellyfin_item_id: track.id.clone(),
}
};
// Build MediaItem with artwork URL from repository and frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
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())),
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
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
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()
}))
})
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
// Add to queue at specified position
let position = match request.position.as_str() {
"next" => AddPosition::Next,
_ => AddPosition::End,
};
let controller = player.0.lock().await;
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.add(vec![media_item], position);
let result = get_queue_status(&controller);
drop(queue_lock);
controller.emit_queue_changed();
info!("Successfully added track {} to queue", request.track_id);
Ok(result)
}
/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
#[tauri::command]
#[specta::specta]
pub async fn player_add_tracks_by_ids(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, RepositoryManagerWrapper>,
repository_handle: String,
request: AddTracksByIdsRequest,
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
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)
.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
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
// Check for local download first
let local_path = check_for_local_download(&db, track_id).await?;
let source = if let Some(path) = local_path {
info!("Using local download for track {}", track_id);
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(track.id.clone()),
}
} else {
// Get stream URL from repository (works online/offline)
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 {
stream_url,
jellyfin_item_id: track.id.clone(),
}
};
// Build MediaItem with artwork URL from repository and frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
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())),
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
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
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()
}))
})
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
media_items.push(media_item);
}
// Add to queue at specified position
let position = match request.position.as_str() {
"next" => AddPosition::Next,
_ => AddPosition::End,
};
let controller = player.0.lock().await;
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.add(media_items, position);
let result = get_queue_status(&controller);
drop(queue_lock);
controller.emit_queue_changed();
info!("Successfully added {} tracks to queue", request.track_ids.len());
Ok(result)
}
#[tauri::command]
#[specta::specta]
pub async fn player_skip_to(
player: State<'_, PlayerStateWrapper>,
index: usize,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
// Skip to the index and get the item to play
let item = {
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
};
// Play the item without modifying the queue
controller.load_and_play(&item).map_err(|e| e.to_string())?;
// Emit queue changed event
controller.emit_queue_changed();
Ok(get_player_status(&controller))
}
+131
View File
@@ -0,0 +1,131 @@
//! Remote Jellyfin session control commands (casting to another device).
//!
//! These thin command adapters forward control actions to the active Jellyfin
//! session via the player's configured `JellyfinClient`.
use tauri::State;
use super::PlayerStateWrapper;
/// Play items on a remote Jellyfin session (casting)
#[tauri::command]
#[specta::specta]
pub async fn remote_play_on_session(
player: State<'_, PlayerStateWrapper>,
session_id: String,
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] Item IDs: {:?}", item_ids);
let client_opt = {
let controller = player.0.lock().await;
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?;
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())
}
}
/// Send a playback command to a remote session
#[tauri::command]
#[specta::specta]
pub async fn remote_send_command(
player: State<'_, PlayerStateWrapper>,
session_id: String,
command: String,
) -> Result<(), String> {
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()
};
if let Some(client) = client_opt {
client.send_session_command(session_id, &command).await?;
log::info!("[RemoteSession] Command sent successfully");
Ok(())
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Seek on a remote session
#[tauri::command]
#[specta::specta]
pub async fn remote_session_seek(
player: State<'_, PlayerStateWrapper>,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
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()
};
if let Some(client) = client_opt {
client.session_seek(session_id, position_ticks).await?;
log::info!("[RemoteSession] Seek successful");
Ok(())
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Set volume on a remote session
#[tauri::command]
#[specta::specta]
pub async fn remote_session_set_volume(
player: State<'_, PlayerStateWrapper>,
session_id: String,
volume: i32,
) -> Result<(), String> {
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()
};
if let Some(client) = client_opt {
client.session_set_volume(session_id, volume).await?;
log::info!("[RemoteSession] Volume set successfully");
Ok(())
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Toggle mute on a remote session
#[tauri::command]
#[specta::specta]
pub async fn remote_session_toggle_mute(
player: State<'_, PlayerStateWrapper>,
session_id: String,
) -> Result<(), String> {
log::info!("[RemoteSession] Toggling mute on session {}", session_id);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.session_toggle_mute(session_id).await?;
log::info!("[RemoteSession] Mute toggled successfully");
Ok(())
} else {
Err("Jellyfin client not configured".to_string())
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Media session state commands.
//!
//! Read and dismiss the current media session (the Now Playing surface backing
//! lockscreen/notification controls).
use tauri::State;
use super::{MediaSessionManagerWrapper, PlayerStateWrapper};
use crate::player::PlayerStatusEvent;
/// Get the current media session state
#[tauri::command]
#[specta::specta]
pub async fn player_get_session(
session: State<'_, MediaSessionManagerWrapper>,
) -> Result<crate::player::session::MediaSessionType, String> {
let manager = session.0.lock().map_err(|e| e.to_string())?;
Ok(manager.current().clone())
}
/// Dismiss the current media session (returns to Idle)
#[tauri::command]
#[specta::specta]
pub async fn player_dismiss_session(
session: State<'_, MediaSessionManagerWrapper>,
player: State<'_, PlayerStateWrapper>,
) -> Result<(), String> {
log::info!("[Session] Dismissing current media session");
// Dismiss the session
{
let mut manager = session.0.lock().map_err(|e| e.to_string())?;
manager.dismiss();
}
// Emit session changed event
if let Some(emitter) = player.0.lock().await.event_emitter() {
let manager = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: manager.current().clone(),
});
}
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
//! Audio and video playback settings commands.
use tauri::State;
use super::{PlayerStateWrapper, VideoSettingsWrapper};
use crate::player::AutoplaySettings;
use crate::settings::{AudioSettings, VideoSettings};
#[tauri::command]
#[specta::specta]
pub async fn player_set_audio_settings(
player: State<'_, PlayerStateWrapper>,
settings: AudioSettings,
) -> Result<AudioSettings, String> {
let mut controller = player.0.lock().await;
controller
.set_audio_settings(&settings)
.map_err(|e| e.to_string())?;
Ok(controller.audio_settings())
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_audio_settings(
player: State<'_, PlayerStateWrapper>,
) -> Result<AudioSettings, String> {
let controller = player.0.lock().await;
Ok(controller.audio_settings())
}
#[tauri::command]
#[specta::specta]
pub async fn player_set_video_settings(
video_settings: State<'_, VideoSettingsWrapper>,
player: State<'_, PlayerStateWrapper>,
settings: VideoSettings,
) -> Result<VideoSettings, String> {
let validated = settings.with_countdown_clamped();
{
let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
*current = validated.clone();
} // Drop MutexGuard before await
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
let controller = player.0.lock().await;
controller.set_autoplay_settings(AutoplaySettings {
enabled: validated.auto_play_next_episode,
countdown_seconds: validated.auto_play_countdown_seconds,
max_episodes: validated.auto_play_max_episodes,
});
Ok(validated)
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_video_settings(
video_settings: State<'_, VideoSettingsWrapper>,
) -> Result<VideoSettings, String> {
let current = video_settings.0.lock().map_err(|e| e.to_string())?;
Ok(current.clone())
}
+237
View File
@@ -0,0 +1,237 @@
//! Sleep-timer and autoplay commands.
//!
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
//! logic, plus persistence of autoplay settings to the database.
use std::sync::Arc;
use tauri::State;
use super::{
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
PlayerStateWrapper,
};
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
// ===== Sleep Timer Commands =====
/// Set sleep timer mode
#[tauri::command]
#[specta::specta]
pub async fn player_set_sleep_timer(
player: State<'_, PlayerStateWrapper>,
mode: SleepTimerMode,
) -> Result<SleepTimerState, String> {
let controller = player.0.lock().await;
controller.set_sleep_timer(mode);
Ok(controller.sleep_timer_state())
}
/// Cancel sleep timer
#[tauri::command]
#[specta::specta]
pub async fn player_cancel_sleep_timer(
player: State<'_, PlayerStateWrapper>,
) -> Result<SleepTimerState, String> {
let controller = player.0.lock().await;
controller.cancel_sleep_timer();
Ok(controller.sleep_timer_state())
}
/// Get current sleep timer state
#[tauri::command]
#[specta::specta]
pub async fn player_get_sleep_timer(
player: State<'_, PlayerStateWrapper>,
) -> Result<SleepTimerState, String> {
let controller = player.0.lock().await;
Ok(controller.sleep_timer_state())
}
// ===== Autoplay Commands =====
/// Get autoplay settings
#[tauri::command]
#[specta::specta]
pub async fn player_get_autoplay_settings(
player: State<'_, PlayerStateWrapper>,
) -> Result<AutoplaySettings, String> {
let controller = player.0.lock().await;
Ok(controller.autoplay_settings())
}
/// Set autoplay settings and persist to database
#[tauri::command]
#[specta::specta]
pub async fn player_set_autoplay_settings(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
user_id: String,
settings: AutoplaySettings,
) -> Result<AutoplaySettings, String> {
let validated = settings.with_validated_countdown();
// Set in controller
{
let controller = player.0.lock().await;
controller.set_autoplay_settings(validated.clone());
}
// Persist to database
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
autoplay_next_episode = excluded.autoplay_next_episode,
autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
autoplay_max_episodes = excluded.autoplay_max_episodes,
updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(user_id),
QueryParam::Int(if validated.enabled { 1 } else { 0 }),
QueryParam::Int(validated.countdown_seconds as i32),
QueryParam::Int(validated.max_episodes as i32),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(validated)
}
/// Cancel active autoplay countdown
#[tauri::command]
#[specta::specta]
pub async fn player_cancel_autoplay_countdown(
player: State<'_, PlayerStateWrapper>,
) -> Result<(), String> {
let controller = player.0.lock().await;
controller.cancel_autoplay_countdown();
Ok(())
}
/// Play next episode (user confirmed from popup)
#[tauri::command]
#[specta::specta]
pub async fn player_play_next_episode(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
) -> Result<PlayerStatus, String> {
// Convert request to MediaItem
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())?;
Ok(get_player_status(&controller))
}
/// Handle playback ended event - triggers autoplay decision logic
/// This is called from:
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly
#[tauri::command]
#[specta::specta]
pub async fn player_on_playback_ended(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
item_id: Option<String>,
repository_handle: Option<String>,
) -> Result<(), String> {
use crate::player::autoplay::AutoplayDecision;
use crate::player::PlayerStatusEvent;
let controller_arc = player.0.clone();
// Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path
// that bypasses the backend queue and stale end_reason
let decision = {
let controller = controller_arc.lock().await;
if let Some(ref id) = item_id {
// Video path: need repository to look up episode info
let repo = repository_handle
.as_ref()
.and_then(|handle| repository_manager.0.get(handle));
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);
AutoplayDecision::Stop
}
} else {
controller.on_playback_ended().await?
}
};
// Handle the decision
match decision {
AutoplayDecision::Stop => {
log::debug!("[Autoplay] Decision: Stop playback");
let controller = controller_arc.lock().await;
if let Some(emitter) = controller.event_emitter() {
// Emit StateChanged to idle to clear the current media from mini player
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
// (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
emitter.emit(PlayerStatusEvent::StateChanged {
state: "idle".to_string(),
media_id: None,
});
}
}
AutoplayDecision::AdvanceToNext => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let controller = controller_arc.lock().await;
if let Err(e) = controller.next() {
log::error!("[Autoplay] Failed to advance to next track: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
// Emit queue changed event so frontend updates UI with new current track
controller.emit_queue_changed();
}
}
AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
} => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = controller_arc.lock().await.event_emitter() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
// Start countdown if auto_advance enabled
if auto_advance {
controller_arc
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
}
}
Ok(())
}
+122
View File
@@ -0,0 +1,122 @@
//! Tauri commands for playlist management
//! Uses handle-based system: UUID -> Arc<HybridRepository>
//!
//! TRACES: UR-014 | JA-019, JA-020
use log::debug;
use tauri::State;
use crate::repository::{MediaRepository, types::*};
use super::repository::RepositoryManagerWrapper;
/// Create a new playlist
#[tauri::command]
#[specta::specta]
pub async fn playlist_create(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
name: String,
item_ids: Option<Vec<String>>,
) -> Result<PlaylistCreatedResult, String> {
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Delete a playlist
#[tauri::command]
#[specta::specta]
pub async fn playlist_delete(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
) -> 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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Rename a playlist
#[tauri::command]
#[specta::specta]
pub async fn playlist_rename(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
name: String,
) -> Result<(), String> {
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get playlist items with PlaylistItemId
#[tauri::command]
#[specta::specta]
pub async fn playlist_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
) -> 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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Add items to a playlist
#[tauri::command]
#[specta::specta]
pub async fn playlist_add_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
item_ids: Vec<String>,
) -> Result<(), String> {
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
#[tauri::command]
#[specta::specta]
pub async fn playlist_remove_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
entry_ids: Vec<String>,
) -> Result<(), String> {
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Move a playlist item to a new position
#[tauri::command]
#[specta::specta]
pub async fn playlist_move_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
playlist_id: String,
item_id: String,
new_index: u32,
) -> Result<(), String> {
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)
.await
.map_err(|e| format!("{:?}", e))
}
+241 -11
View File
@@ -1,11 +1,15 @@
// Tauri commands for repository access
// Uses handle-based system: UUID -> Arc<HybridRepository>
//! Tauri commands for repository access
//! Uses handle-based system: UUID -> Arc<HybridRepository>
//!
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
use crate::utils::lock::MutexSafe;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
@@ -24,17 +28,17 @@ impl RepositoryManager {
}
pub fn create(&self, handle: String, repository: HybridRepository) {
let mut repos = self.repositories.lock().unwrap();
let mut repos = self.repositories.lock_safe();
repos.insert(handle, Arc::new(repository));
}
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
let repos = self.repositories.lock().unwrap();
let repos = self.repositories.lock_safe();
repos.get(handle).cloned()
}
pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock().unwrap();
let mut repos = self.repositories.lock_safe();
repos.remove(handle);
}
}
@@ -45,9 +49,11 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository
#[tauri::command]
#[specta::specta]
pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String,
user_id: String,
access_token: String,
@@ -64,9 +70,18 @@ pub async fn repository_create(
})?;
debug!("[REPO] HTTP client created successfully");
// Create online repository
// Grab a connectivity reporter so the online repository's server outcomes
// drive the reachability state the UI observes (source of truth for the
// offline/online banner). See docs/architecture/07-connectivity.md.
let connectivity_reporter = {
let monitor = connectivity.0.lock().await;
monitor.reporter()
};
// 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);
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
@@ -105,6 +120,7 @@ pub async fn repository_create(
/// Destroy a repository instance
#[tauri::command]
#[specta::specta]
pub async fn repository_destroy(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -115,6 +131,7 @@ pub async fn repository_destroy(
/// Get libraries
#[tauri::command]
#[specta::specta]
pub async fn repository_get_libraries(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -135,6 +152,7 @@ pub async fn repository_get_libraries(
/// Get items in a container (library, folder, album, etc.)
#[tauri::command]
#[specta::specta]
pub async fn repository_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -149,6 +167,7 @@ pub async fn repository_get_items(
/// Get a single item by ID
#[tauri::command]
#[specta::specta]
pub async fn repository_get_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -162,6 +181,7 @@ pub async fn repository_get_item(
/// Get latest items in a library
#[tauri::command]
#[specta::specta]
pub async fn repository_get_latest_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -176,6 +196,7 @@ pub async fn repository_get_latest_items(
/// Get resume items (continue watching/listening)
#[tauri::command]
#[specta::specta]
pub async fn repository_get_resume_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -198,6 +219,7 @@ pub async fn repository_get_resume_items(
/// Get next up episodes
#[tauri::command]
#[specta::specta]
pub async fn repository_get_next_up_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -212,6 +234,7 @@ pub async fn repository_get_next_up_episodes(
/// Get recently played audio
#[tauri::command]
#[specta::specta]
pub async fn repository_get_recently_played_audio(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -225,6 +248,7 @@ pub async fn repository_get_recently_played_audio(
/// Get resume movies
#[tauri::command]
#[specta::specta]
pub async fn repository_get_resume_movies(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -236,8 +260,24 @@ pub async fn repository_get_resume_movies(
.map_err(|e| format!("{:?}", e))
}
/// Get albums the user hasn't listened to recently ("rediscover")
#[tauri::command]
#[specta::specta]
pub async fn repository_get_rediscover_albums(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
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)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
#[specta::specta]
pub async fn repository_get_genres(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -249,22 +289,78 @@ pub async fn repository_get_genres(
.map_err(|e| format!("{:?}", e))
}
/// Tauri event name carrying the merged (cache + server) search results.
pub const SEARCH_EVENT_NAME: &str = "search-event";
/// Payload for the deferred, merged search results pushed to the frontend.
///
/// `request_id` matches the value the frontend passed to `repository_search`,
/// letting it discard updates from queries that have since been superseded.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchUpdateEvent {
pub request_id: u32,
pub result: SearchResult,
}
/// Search for items
#[tauri::command]
#[specta::specta]
pub async fn repository_search(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
.search_cache_only(&query, options.clone())
.await
.map_err(|e| format!("{:?}", e))
.unwrap_or_else(|e| {
debug!("[Search] Cache search miss/timeout: {:?}", e);
SearchResult {
items: Vec::new(),
total_record_count: 0,
}
});
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
let repo_bg = repo.clone();
let cache_for_merge = cache_result.clone();
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 event = SearchUpdateEvent {
request_id,
result: merged,
};
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
error!("[Search] Failed to emit search update: {}", e);
}
}
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);
}
}
});
Ok(cache_result)
}
/// Get playback info for an item
#[tauri::command]
#[specta::specta]
pub async fn repository_get_playback_info(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -278,6 +374,7 @@ pub async fn repository_get_playback_info(
/// Get video stream URL with optional seeking support
#[tauri::command]
#[specta::specta]
pub async fn repository_get_video_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -300,6 +397,7 @@ pub async fn repository_get_video_stream_url(
/// Get audio stream URL for a track
#[tauri::command]
#[specta::specta]
pub async fn repository_get_audio_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -314,6 +412,7 @@ pub async fn repository_get_audio_stream_url(
/// Report playback start
#[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -328,6 +427,7 @@ pub async fn repository_report_playback_start(
/// Report playback progress
#[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -342,6 +442,7 @@ pub async fn repository_report_playback_progress(
/// Report playback stopped
#[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -356,6 +457,7 @@ pub async fn repository_report_playback_stopped(
/// Get image URL for an item
#[tauri::command]
#[specta::specta]
pub fn repository_get_image_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -369,6 +471,8 @@ pub fn repository_get_image_url(
/// Get subtitle URL for a media item
#[tauri::command]
#[specta::specta]
#[allow(dead_code)]
pub fn repository_get_subtitle_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -383,6 +487,8 @@ pub fn repository_get_subtitle_url(
/// Get video download URL with quality preset
#[tauri::command]
#[specta::specta]
#[allow(dead_code)]
pub fn repository_get_video_download_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -396,6 +502,7 @@ pub fn repository_get_video_download_url(
/// Mark an item as favorite
#[tauri::command]
#[specta::specta]
pub async fn repository_mark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -409,6 +516,7 @@ pub async fn repository_mark_favorite(
/// Unmark an item as favorite
#[tauri::command]
#[specta::specta]
pub async fn repository_unmark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -422,6 +530,7 @@ pub async fn repository_unmark_favorite(
/// Get person details
#[tauri::command]
#[specta::specta]
pub async fn repository_get_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -435,6 +544,7 @@ pub async fn repository_get_person(
/// Get items by person (actor, director, etc.)
#[tauri::command]
#[specta::specta]
pub async fn repository_get_items_by_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -449,6 +559,7 @@ pub async fn repository_get_items_by_person(
/// Get similar/related items for a media item
#[tauri::command]
#[specta::specta]
pub async fn repository_get_similar_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
@@ -460,3 +571,122 @@ pub async fn repository_get_similar_items(
.await
.map_err(|e| format!("{:?}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repository_manager_creation() {
let manager = RepositoryManager::new();
// A freshly created manager holds no repositories
assert!(manager.get("any-handle").is_none());
}
#[test]
fn test_repository_manager_wrapper_structure() {
let manager = RepositoryManager::new();
let wrapper = RepositoryManagerWrapper(manager);
// The wrapper exposes the underlying manager, which starts empty
assert!(wrapper.0.get("any-handle").is_none());
}
#[test]
fn test_repository_manager_get_nonexistent() {
let manager = RepositoryManager::new();
// Getting a non-existent repository should return None
let result = manager.get("nonexistent-handle");
assert!(result.is_none());
}
#[test]
fn test_uuid_handle_generation() {
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
// UUID should convert to a non-empty string
assert!(!handle.is_empty());
assert!(handle.len() > 0);
}
#[test]
fn test_uuid_handles_are_unique() {
let handle1 = format!("{}", Uuid::new_v4());
let handle2 = format!("{}", Uuid::new_v4());
// Two generated UUIDs should be different
assert_ne!(handle1, handle2);
}
#[test]
fn test_uuid_handle_format() {
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
// UUID should have standard format with hyphens
let parts: Vec<&str> = handle.split('-').collect();
assert_eq!(parts.len(), 5);
}
#[test]
fn test_repository_manager_destroy_nonexistent() {
let manager = RepositoryManager::new();
// Destroying a non-existent repository should not panic
manager.destroy("nonexistent-handle");
}
#[test]
fn test_repository_manager_is_send_sync() {
// Verify RepositoryManager can be used in async contexts
fn is_send_sync<T: Send + Sync>() {}
is_send_sync::<RepositoryManager>();
}
#[test]
fn test_repository_manager_wrapper_is_send_sync() {
// Verify RepositoryManagerWrapper is Send + Sync
fn is_send_sync<T: Send + Sync>() {}
is_send_sync::<RepositoryManagerWrapper>();
}
#[test]
fn test_multiple_manager_instances() {
let manager1 = RepositoryManager::new();
let manager2 = RepositoryManager::new();
// Multiple manager instances should be independent
let handle1_nonexistent = manager1.get("test");
let handle2_nonexistent = manager2.get("test");
assert!(handle1_nonexistent.is_none());
assert!(handle2_nonexistent.is_none());
}
#[test]
fn test_handle_string_properties() {
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
// Handle should be alphanumeric with hyphens
for c in handle.chars() {
assert!(c.is_alphanumeric() || c == '-');
}
}
#[test]
fn test_repository_manager_concurrent_access() {
let manager = Arc::new(RepositoryManager::new());
let mut handles = vec![];
// Verify manager can be wrapped in Arc for concurrent access
for _ in 0..3 {
let mgr = Arc::clone(&manager);
let handle = std::thread::spawn(move || {
let result = mgr.get("test");
assert!(result.is_none());
});
handles.push(handle);
}
for h in handles {
h.join().unwrap();
}
}
}
+70
View File
@@ -1,3 +1,5 @@
//! TRACES: UR-010 | JA-021 | DR-037
use std::sync::Arc;
use tauri::State;
use crate::session_poller::{PollingHint, SessionPollerManager};
@@ -8,6 +10,7 @@ pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
/// Set polling frequency hint based on UI state
#[tauri::command]
#[specta::specta]
pub fn sessions_set_polling_hint(
poller: State<'_, SessionPollerWrapper>,
hint: String,
@@ -25,8 +28,75 @@ pub fn sessions_set_polling_hint(
/// Manually trigger a session poll (for refresh button)
#[tauri::command]
#[specta::specta]
pub async fn sessions_poll_now(
poller: State<'_, SessionPollerWrapper>,
) -> Result<Vec<SessionInfo>, String> {
poller.0.poll_now().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_polling_hint_parsing() {
// Test valid hints
assert_eq!(
match "cast_active" {
"cast_active" => Some(PollingHint::CastActive),
"cast_discovery" => Some(PollingHint::CastDiscovery),
"normal" => Some(PollingHint::Normal),
_ => None,
},
Some(PollingHint::CastActive)
);
assert_eq!(
match "cast_discovery" {
"cast_active" => Some(PollingHint::CastActive),
"cast_discovery" => Some(PollingHint::CastDiscovery),
"normal" => Some(PollingHint::Normal),
_ => None,
},
Some(PollingHint::CastDiscovery)
);
assert_eq!(
match "normal" {
"cast_active" => Some(PollingHint::CastActive),
"cast_discovery" => Some(PollingHint::CastDiscovery),
"normal" => Some(PollingHint::Normal),
_ => None,
},
Some(PollingHint::Normal)
);
}
#[test]
fn test_invalid_polling_hint() {
// Test invalid hint
let result = match "invalid" {
"cast_active" => Ok(PollingHint::CastActive),
"cast_discovery" => Ok(PollingHint::CastDiscovery),
"normal" => Ok(PollingHint::Normal),
_ => Err("Invalid polling hint"),
};
assert!(result.is_err());
}
#[test]
fn test_session_poller_wrapper_structure() {
// Test that wrapper type structure is correct
assert_eq!(std::mem::size_of::<SessionPollerWrapper>() > 0, true);
}
#[test]
fn test_polling_hints_exist() {
// Verify polling hint variants exist
let _ = PollingHint::CastActive;
let _ = PollingHint::CastDiscovery;
let _ = PollingHint::Normal;
}
}
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
//! Person/cast metadata cache commands.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
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")]
pub struct CachedPerson {
pub id: String,
pub server_id: String,
pub name: String,
pub overview: Option<String>,
pub primary_image_tag: Option<String>,
pub premiere_date: Option<String>,
pub end_date: Option<String>,
}
/// Item-person association for caching
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedItemPerson {
pub item_id: String,
pub person_id: String,
pub server_id: String,
pub person_type: String,
pub role: Option<String>,
pub sort_order: i32,
}
/// Save a person to the cache
#[tauri::command]
#[specta::specta]
pub async fn storage_save_person(
db: State<'_, DatabaseWrapper>,
person: CachedPerson,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT OR REPLACE INTO people (
id, server_id, name, overview, primary_image_tag,
premiere_date, end_date, synced_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
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),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get a cached person by ID
#[tauri::command]
#[specta::specta]
pub async fn storage_get_person(
db: State<'_, DatabaseWrapper>,
person_id: String,
) -> Result<Option<CachedPerson>, 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 id, server_id, name, overview, primary_image_tag, premiere_date, end_date
FROM people WHERE id = ?",
vec![QueryParam::String(person_id)],
);
let result = db_service
.query_optional(query, |row| {
Ok(CachedPerson {
id: row.get(0)?,
server_id: row.get(1)?,
name: row.get(2)?,
overview: row.get(3)?,
primary_image_tag: row.get(4)?,
premiere_date: row.get(5)?,
end_date: row.get(6)?,
})
})
.await
.map_err(|e| e.to_string())?;
Ok(result)
}
/// Save item-person associations (batch)
#[tauri::command]
#[specta::specta]
pub async fn storage_save_item_people(
db: State<'_, DatabaseWrapper>,
associations: Vec<CachedItemPerson>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Clone associations for the closure
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 (
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())?;
Ok(())
}
/// Get people for an item (with person details joined)
#[tauri::command]
#[specta::specta]
pub async fn storage_get_item_people(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<Vec<CachedItemPerson>, 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 ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order
FROM item_people ip
WHERE ip.item_id = ?
ORDER BY ip.sort_order ASC",
vec![QueryParam::String(item_id)],
);
let people = db_service
.query_many(query, |row| {
Ok(CachedItemPerson {
item_id: row.get(0)?,
person_id: row.get(1)?,
server_id: row.get(2)?,
person_type: row.get(3)?,
role: row.get(4)?,
sort_order: row.get(5)?,
})
})
.await
.map_err(|e| e.to_string())?;
Ok(people)
}
@@ -0,0 +1,94 @@
//! Per-series preferred audio track commands.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
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")]
pub struct SeriesAudioPreference {
pub series_id: String,
pub audio_track_display_title: Option<String>,
pub audio_track_language: Option<String>,
pub audio_track_index: Option<i32>,
}
/// Save user's preferred audio track for a series
#[tauri::command]
#[specta::specta]
pub async fn storage_save_series_audio_preference(
db: State<'_, DatabaseWrapper>,
user_id: String,
series_id: String,
server_id: String,
audio_track_display_title: Option<String>,
audio_track_language: Option<String>,
audio_track_index: Option<i32>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO series_audio_preferences
(user_id, series_id, server_id, audio_track_display_title, audio_track_language, audio_track_index, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (user_id, series_id, server_id) DO UPDATE SET
audio_track_display_title = excluded.audio_track_display_title,
audio_track_language = excluded.audio_track_language,
audio_track_index = excluded.audio_track_index,
updated_at = datetime('now')",
vec![
QueryParam::String(user_id),
QueryParam::String(series_id),
QueryParam::String(server_id),
audio_track_display_title.map(QueryParam::String).unwrap_or(QueryParam::Null),
audio_track_language.map(QueryParam::String).unwrap_or(QueryParam::Null),
audio_track_index.map(|i| QueryParam::Int64(i as i64)).unwrap_or(QueryParam::Null),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get user's preferred audio track for a series
#[tauri::command]
#[specta::specta]
pub async fn storage_get_series_audio_preference(
db: State<'_, DatabaseWrapper>,
user_id: String,
series_id: String,
) -> Result<Option<SeriesAudioPreference>, 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 series_id, audio_track_display_title, audio_track_language, audio_track_index
FROM series_audio_preferences
WHERE user_id = ? AND series_id = ?",
vec![QueryParam::String(user_id), QueryParam::String(series_id)],
);
let preference = db_service
.query_optional(query, |row| {
Ok(SeriesAudioPreference {
series_id: row.get(0)?,
audio_track_display_title: row.get(1)?,
audio_track_language: row.get(2)?,
audio_track_index: row.get(3)?,
})
})
.await
.map_err(|e| e.to_string())?;
Ok(preference)
}
@@ -0,0 +1,248 @@
//! Thumbnail cache and image-URL commands.
use std::sync::{Arc, OnceLock};
use tokio::sync::Semaphore;
use serde::Deserialize;
use tauri::State;
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
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]
#[specta::specta]
pub async fn thumbnail_get_cached(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
image_type: String,
tag: String,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let result = thumbnail_cache.0
.get_cached_path(db_service, &item_id, &image_type, &tag)
.await
.map(|p| p.to_string_lossy().to_string());
Ok(result)
}
/// Download and save a thumbnail to cache
/// Returns the local file path on success
#[tauri::command]
#[specta::specta]
pub async fn thumbnail_save(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
image_type: String,
tag: String,
url: String,
) -> Result<String, String> {
// Download the image
let worker = ThumbnailWorker::new();
let data = worker
.download_with_retry(&url, 2)
.await
.map_err(|e| e.to_string())?;
// Save to cache
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
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())
}
/// Get thumbnail cache statistics
#[tauri::command]
#[specta::specta]
pub async fn thumbnail_get_stats(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
) -> Result<ThumbnailCacheStats, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await;
let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await;
let limit_bytes = thumbnail_cache.0.get_limit(db_service).await;
Ok(ThumbnailCacheStats {
total_size_bytes,
item_count,
limit_bytes,
})
}
/// Set thumbnail cache storage limit in bytes
#[tauri::command]
#[specta::specta]
pub async fn thumbnail_set_limit(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
limit_bytes: u64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.set_limit(db_service, limit_bytes).await
}
/// Clear all cached thumbnails
#[tauri::command]
#[specta::specta]
pub async fn thumbnail_clear_cache(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.clear_cache(db_service).await
}
/// Delete cached thumbnails for a specific item
#[tauri::command]
#[specta::specta]
pub async fn thumbnail_delete_item(
db: State<'_, DatabaseWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
item_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
thumbnail_cache.0.delete_item(db_service, &item_id).await
}
fn mime_from_ext(ext: Option<&str>) -> &'static str {
match ext {
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg",
}
}
/// Limit concurrent image downloads to avoid saturating the connection pool.
/// Without this, rendering a page with hundreds of album cards fires hundreds of
/// concurrent HTTP requests, starving API calls and causing timeouts.
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
fn image_semaphore() -> &'static Semaphore {
IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
}
/// Request to get an image URL (with caching)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetImageRequest {
pub item_id: String,
pub image_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
/// Get image as base64 data URL, caching if not already cached
/// This extends the thumbnail system to serve all images through Rust with automatic caching
#[tauri::command]
#[specta::specta]
pub async fn image_get_url(
repository_manager: State<'_, RepositoryManagerWrapper>,
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
db: State<'_, DatabaseWrapper>,
repository_handle: String,
request: GetImageRequest,
) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use std::fs;
let tag = request.tag.as_deref().unwrap_or("default");
// Get database service
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// 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))?;
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));
}
// Not cached — fetch from server and cache.
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
let _permit = image_semaphore().acquire().await
.map_err(|_| "Image download semaphore closed".to_string())?;
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() {
"Primary" => ImageType::Primary,
"Backdrop" => ImageType::Backdrop,
"Banner" => ImageType::Banner,
"Thumb" => ImageType::Thumb,
"Logo" => ImageType::Logo,
_ => ImageType::Primary,
};
let options = ImageOptions {
max_width: request.max_width,
max_height: request.max_height,
quality: Some(90),
tag: request.tag.clone(),
};
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
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 base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
Ok(format!("data:{};base64,{}", mime_type, base64_data))
}
+144 -1
View File
@@ -12,7 +12,7 @@ use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncQueueItem {
pub id: i64,
@@ -28,6 +28,7 @@ pub struct SyncQueueItem {
/// Queue a mutation for sync to server
#[tauri::command]
#[specta::specta]
pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>,
user_id: String,
@@ -59,6 +60,7 @@ pub async fn sync_queue_mutation(
/// Get all pending sync operations for a user
#[tauri::command]
#[specta::specta]
pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>,
user_id: String,
@@ -107,6 +109,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,
@@ -127,6 +130,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,
@@ -147,6 +151,7 @@ pub async fn sync_mark_completed(
/// Mark a sync operation as failed with error message
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>,
id: i64,
@@ -173,6 +178,7 @@ pub async fn sync_mark_failed(
/// Get count of pending sync operations for a user
#[tauri::command]
#[specta::specta]
pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>,
user_id: String,
@@ -195,6 +201,7 @@ pub async fn sync_get_pending_count(
/// Delete completed sync operations older than specified days
#[tauri::command]
#[specta::specta]
pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>,
days_old: i32,
@@ -217,6 +224,7 @@ pub async fn sync_cleanup_completed(
/// Delete all sync operations for a user (used during logout)
#[tauri::command]
#[specta::specta]
pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>,
user_id: String,
@@ -234,3 +242,138 @@ pub async fn sync_clear_user(
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_queue_item_serialization() {
let item = SyncQueueItem {
id: 1,
user_id: "user-123".to_string(),
operation: "favorite".to_string(),
item_id: Some("item-456".to_string()),
payload: Some(r#"{"isFavorite": true}"#.to_string()),
status: "pending".to_string(),
retry_count: 0,
created_at: Some("2024-02-14T08:00:00Z".to_string()),
error_message: None,
};
// Should serialize successfully
let json = serde_json::to_string(&item);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("user-123"));
assert!(serialized.contains("favorite"));
assert!(serialized.contains("pending"));
}
#[test]
fn test_sync_queue_item_with_error() {
let item = SyncQueueItem {
id: 2,
user_id: "user-789".to_string(),
operation: "update_progress".to_string(),
item_id: Some("item-999".to_string()),
payload: None,
status: "failed".to_string(),
retry_count: 3,
created_at: Some("2024-02-14T07:00:00Z".to_string()),
error_message: Some("Connection timeout".to_string()),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("failed"));
assert!(json.contains("Connection timeout"));
assert!(json.contains("3")); // retry_count
}
#[test]
fn test_sync_queue_item_without_optional_fields() {
let item = SyncQueueItem {
id: 3,
user_id: "user-000".to_string(),
operation: "clear_progress".to_string(),
item_id: None,
payload: None,
status: "completed".to_string(),
retry_count: 0,
created_at: None,
error_message: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("completed"));
assert!(json.contains("null") || json.contains("\"itemId\":null"));
}
#[test]
fn test_sync_status_values() {
// Verify all expected status values
let valid_statuses = vec!["pending", "processing", "completed", "failed"];
for status in valid_statuses {
let item = SyncQueueItem {
id: 1,
user_id: "test".to_string(),
operation: "test".to_string(),
item_id: None,
payload: None,
status: status.to_string(),
retry_count: 0,
created_at: None,
error_message: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains(status));
}
}
#[test]
fn test_query_param_generation() {
// Test QueryParam generation for sync operations
let user_id = "user-123".to_string();
let operation = "favorite".to_string();
let params: Vec<QueryParam> = vec![
QueryParam::String(user_id.clone()),
QueryParam::String(operation.clone()),
QueryParam::Null,
QueryParam::Null,
];
assert_eq!(params.len(), 4);
assert!(matches!(params[0], QueryParam::String(_)));
assert!(matches!(params[1], QueryParam::String(_)));
assert!(matches!(params[2], QueryParam::Null));
assert!(matches!(params[3], QueryParam::Null));
}
#[test]
fn test_retry_count_increment() {
// Verify retry count management
let mut item = SyncQueueItem {
id: 1,
user_id: "user-123".to_string(),
operation: "favorite".to_string(),
item_id: None,
payload: None,
status: "pending".to_string(),
retry_count: 0,
created_at: None,
error_message: None,
};
// Simulate retries
for i in 1..=5 {
item.retry_count = i;
item.status = if i < 3 { "pending" } else { "failed" }.to_string();
assert!(item.retry_count == i);
}
}
}
+358 -232
View File
@@ -1,18 +1,26 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript)
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
// Offline recovery probe interval.
// Reachability while online is driven by real repository traffic, so there is
// no online polling. While offline we probe quickly to detect the server
// returning even when no user traffic is flowing.
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
// Time-window debounce for declaring the server offline.
// A single dropped request must not trip the banner: we only flip to offline
// once network failures have persisted continuously for this window with no
// intervening success. Recovery (online) is instant on the first success.
const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5);
/// Connectivity status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectivityStatus {
/// Whether the Jellyfin server is reachable
@@ -39,216 +47,122 @@ impl Default for ConnectivityStatus {
}
/// Connectivity change event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[derive(specta::Type, Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectivityChangeEvent {
is_reachable: bool,
}
/// Connectivity monitor for tracking server reachability
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
/// Shared reachability state and transition logic.
///
/// This is the single place that mutates reachability and emits events. It is
/// cheap to clone (all fields are `Arc`/`Option`) and is shared by:
/// - the `ConnectivityMonitor` (commands, offline recovery probe), and
/// - `OnlineRepository`, which reports the outcome of every server request.
///
/// Reachability is therefore driven by real traffic; the probe only fills the
/// gap while offline.
#[derive(Clone)]
pub struct ConnectivityReporter {
status: Arc<RwLock<ConnectivityStatus>>,
is_monitoring: Arc<AtomicBool>,
/// Timestamp of the first network failure in the current failure streak.
/// Used to debounce the transition to offline (see `OFFLINE_CONFIRM_WINDOW`).
first_failure_at: Arc<RwLock<Option<Instant>>>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
impl ConnectivityReporter {
fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
status,
first_failure_at: Arc::new(RwLock::new(None)),
app_handle,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
/// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity.
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
let mut server_url = self.server_url.write().await;
*server_url = Some(url.clone());
drop(server_url);
// 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" });
/// Test-only: force the reporter into the offline state without going through
/// the debounce, so other modules' tests can set up an "offline" precondition.
#[cfg(test)]
pub async fn mark_unreachable_for_test(&self) {
self.apply_probe_result(false, Some("forced offline (test)".to_string()))
.await;
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.status.read().await.clone()
/// Report that a real server request succeeded (or that the server answered
/// at all, e.g. with 401/404/5xx). The server is up — recover instantly.
pub async fn report_success(&self) {
*self.first_failure_at.write().await = None;
self.set_reachable(true, None).await;
}
/// Check if the Jellyfin server is reachable
pub async fn check_reachability(&self) -> bool {
// Mark as checking
{
let mut status = self.status.write().await;
status.is_checking = true;
/// Report that a real server request failed with a network-level error
/// (connection refused, timeout, DNS). Subject to the time-window debounce:
/// we only flip to offline once failures have persisted for
/// `OFFLINE_CONFIRM_WINDOW` with no intervening success.
pub async fn report_network_failure(&self, error: Option<String>) {
// If already offline, nothing to debounce.
if !self.status.read().await.is_server_reachable {
return;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
let mut status = self.status.write().await;
status.is_server_reachable = false;
status.connection_error = Some("No server URL configured".to_string());
status.is_checking = false;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
let now = Instant::now();
let streak_start = {
let mut first = self.first_failure_at.write().await;
*first.get_or_insert(now)
};
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW {
log::warn!(
"[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
OFFLINE_CONFIRM_WINDOW
);
self.set_reachable(false, error).await;
} else {
log::debug!(
"[ConnectivityMonitor] Network failure within debounce window; not yet offline"
);
}
}
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
/// Apply a deliberate reachability probe result (offline recovery probe or a
/// manual check). Unlike `report_network_failure`, a probe is an explicit
/// reachability test, so its result is applied immediately without debounce.
async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>) {
if is_reachable {
*self.first_failure_at.write().await = None;
}
self.set_reachable(is_reachable, error).await;
}
log::debug!(
"[ConnectivityMonitor] Ping result: {} (was: {})",
if is_reachable { "SUCCESS" } else { "FAILED" },
if was_reachable { "reachable" } else { "unreachable" }
);
// Update status
{
/// Core transition: update status and emit events only on an actual change.
async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
let was_reachable = {
let mut status = self.status.write().await;
let was = status.is_server_reachable;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
};
status.is_checking = false;
}
was
};
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
/// Mark server as reachable (called after successful API call)
pub async fn mark_reachable(&self) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = true;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = None;
drop(status);
if !was_reachable {
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
self.emit_connectivity_change(true).await;
self.emit_server_reconnected().await;
}
}
/// Mark server as unreachable (called after failed API call)
pub async fn mark_unreachable(&self, error: Option<String>) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = false;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
let error_msg = status.connection_error.clone().unwrap_or_default();
drop(status);
if was_reachable {
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
self.emit_connectivity_change(false).await;
}
}
/// Start monitoring connectivity with adaptive polling
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
// Perform immediate check before starting background task
// This ensures we get an accurate state right away instead of assuming offline
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
// Clone Arc references for the background task
let status = Arc::clone(&self.status);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let self_clone = Arc::new(ConnectivityMonitorHandle {
server_url,
http_client,
status,
app_handle: self.app_handle.clone(),
});
// Spawn background monitoring task
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
// Determine interval based on current reachability
let interval_ms = {
let status = self_clone.status.read().await;
if status.is_server_reachable {
AUTO_CHECK_INTERVAL_MS
} else {
RETRY_CHECK_INTERVAL_MS
}
};
// Wait for the interval
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
// Check if still monitoring
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Perform connectivity check
let _ = self_clone.check_reachability().await;
if is_reachable {
self.emit_server_reconnected().await;
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
}
/// Emit connectivity change event to frontend
@@ -275,87 +189,183 @@ impl ConnectivityMonitor {
}
}
/// Handle for the background monitoring task
struct ConnectivityMonitorHandle {
/// Connectivity monitor for tracking server reachability.
///
/// Reachability is driven primarily by real repository traffic via the shared
/// [`ConnectivityReporter`]. The monitor itself only runs an offline recovery
/// probe (see `start_monitoring`) and serves the connectivity Tauri commands.
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
app_handle: Option<AppHandle>,
reporter: ConnectivityReporter,
is_monitoring: Arc<AtomicBool>,
}
impl ConnectivityMonitorHandle {
async fn check_reachability(&self) -> bool {
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
let status = Arc::new(RwLock::new(ConnectivityStatus::default()));
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
reporter: ConnectivityReporter::new(status, None),
is_monitoring: Arc::new(AtomicBool::new(false)),
}
}
/// Set the Tauri app handle for event emission.
/// Must be called before the reporter is shared with the repository.
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.reporter.app_handle = Some(app_handle);
}
/// Get a cheap, cloneable reporter so the repository can feed server
/// outcomes into the same reachability state the UI observes.
pub fn reporter(&self) -> ConnectivityReporter {
self.reporter.clone()
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
*self.server_url.write().await = Some(url);
// 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" });
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.reporter.status.read().await.clone()
}
/// Deliberately probe the server's reachability (manual check / recovery probe).
/// The result is applied immediately (no debounce) since this is an explicit test.
pub async fn check_reachability(&self) -> bool {
{
let mut status = self.reporter.status.write().await;
status.is_checking = true;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
let Some(url) = server_url else {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
self.reporter
.apply_probe_result(false, Some("No server URL configured".to_string()))
.await;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
// Attempt to ping the server
let ping_url = format!("{}/System/Info/Public", url);
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {}",
if is_reachable { "SUCCESS" } else { "FAILED" }
);
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
self.reporter.apply_probe_result(is_reachable, None).await;
is_reachable
}
async fn emit_connectivity_change(&self, is_reachable: bool) {
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);
}
}
/// Mark server as reachable (called after successful API call / login)
pub async fn mark_reachable(&self) {
self.reporter.report_success().await;
}
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);
}
/// Mark server as unreachable directly.
///
/// Used by deliberate signals (e.g. a failed login/connect) where the caller
/// knows the server is unreachable now. Repository traffic should prefer
/// `reporter().report_network_failure()` so the debounce applies.
pub async fn mark_unreachable(&self, error: Option<String>) {
self.reporter.apply_probe_result(false, error).await;
}
/// Start the offline recovery probe.
///
/// While **online**, reachability is kept fresh by real traffic, so the probe
/// idles. While **offline**, it polls `/System/Info/Public` every
/// `RETRY_CHECK_INTERVAL_MS` to detect the server returning even when no user
/// traffic is flowing.
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
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" });
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let reporter = self.reporter.clone();
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await;
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Only probe while offline — real traffic is the signal when online.
if reporter.status.read().await.is_server_reachable {
continue;
}
let Some(url) = server_url.read().await.clone() else {
continue;
};
let ping_url = format!("{}/System/Info/Public", url);
let is_reachable = http_client.ping(&ping_url).await;
// Probe only ever recovers us to online; a failed probe leaves us
// offline without re-emitting (no change).
if is_reachable {
reporter.apply_probe_result(true, None).await;
}
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jellyfin::http_client::HttpConfig;
/// Build a reporter backed by a fresh (optimistic) status, with no app handle.
/// Event emission is a no-op without a handle, which is exactly what we want
/// for unit-testing the reachability state transitions.
fn test_reporter() -> ConnectivityReporter {
ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None)
}
async fn is_reachable(reporter: &ConnectivityReporter) -> bool {
reporter.status.read().await.is_server_reachable
}
#[test]
fn test_intervals() {
// Verify intervals match TypeScript
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
// Offline recovery probe interval (online has no polling).
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
}
#[tokio::test]
@@ -367,4 +377,120 @@ mod tests {
assert!(status.connection_error.is_none());
assert!(!status.is_checking);
}
/// A single (or brief) network failure must NOT flip the app offline:
/// the time-window debounce keeps us online until the failure persists.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_single_network_failure_does_not_go_offline() {
let reporter = test_reporter();
assert!(is_reachable(&reporter).await, "starts online");
reporter
.report_network_failure(Some("timeout".to_string()))
.await;
assert!(
is_reachable(&reporter).await,
"one network failure within the debounce window stays online"
);
// But the failure streak is now being tracked.
assert!(reporter.first_failure_at.read().await.is_some());
}
/// Once failures persist past OFFLINE_CONFIRM_WINDOW, we flip offline.
/// We simulate elapsed time by backdating the streak start.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_sustained_network_failure_goes_offline() {
let reporter = test_reporter();
// First failure starts the streak.
reporter.report_network_failure(None).await;
assert!(is_reachable(&reporter).await);
// Backdate the streak start to before the window.
{
let mut first = reporter.first_failure_at.write().await;
*first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1));
}
// Next failure now exceeds the window → offline.
reporter
.report_network_failure(Some("connection refused".to_string()))
.await;
assert!(
!is_reachable(&reporter).await,
"sustained network failure flips to offline"
);
}
/// A success during a failure streak clears the streak and keeps us online —
/// recovery is instant and never trips the banner.
#[tokio::test]
async fn test_success_clears_failure_streak() {
let reporter = test_reporter();
reporter.report_network_failure(None).await;
assert!(reporter.first_failure_at.read().await.is_some());
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
assert!(
reporter.first_failure_at.read().await.is_none(),
"success resets the debounce streak"
);
}
/// First success after being offline recovers instantly (no debounce on the
/// way back up).
#[tokio::test]
async fn test_recovery_is_instant() {
let reporter = test_reporter();
// Force offline.
reporter.apply_probe_result(false, Some("down".to_string())).await;
assert!(!is_reachable(&reporter).await);
// A single success brings us straight back online.
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
let status = reporter.status.read().await;
assert!(status.connection_error.is_none());
}
/// Server-answered errors (401/404/5xx) are reported via report_success
/// by the repository, because the server is demonstrably reachable. This
/// test documents that contract: report_success means "server is up".
#[tokio::test]
async fn test_server_answered_error_counts_as_reachable() {
let reporter = test_reporter();
// Simulate being offline, then the server answers (even with an error).
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Repository maps Authentication/NotFound/Server errors to report_success.
reporter.report_success().await;
assert!(
is_reachable(&reporter).await,
"a server that answers (even with 4xx/5xx) is reachable"
);
}
/// report_network_failure is a no-op once already offline (nothing to debounce,
/// no duplicate events).
#[tokio::test]
async fn test_network_failure_noop_when_already_offline() {
let reporter = test_reporter();
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Should not panic or change state.
reporter.report_network_failure(Some("still down".to_string())).await;
assert!(!is_reachable(&reporter).await);
}
}
+2
View File
@@ -6,6 +6,8 @@
//!
//! The fallback is less secure as the encryption key is derived from machine
//! identifiers, but provides functionality on headless systems.
//!
//! TRACES: UR-012 | IR-014
use aes_gcm::{
aead::{Aead, KeyInit},
+4 -2
View File
@@ -1,5 +1,7 @@
//! Smart caching engine for predictive downloads
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::{debug, info};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Smart caching configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheConfig {
/// Enable queue pre-caching
@@ -309,7 +311,7 @@ mod tests {
// Add some downloads
{
let conn_guard = conn_arc.lock().unwrap();
let conn_guard = conn_arc.lock_safe();
conn_guard.execute(
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
[],
+6 -6
View File
@@ -10,6 +10,7 @@ pub mod cache;
pub mod events;
pub mod worker;
use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
@@ -45,18 +46,18 @@ impl DownloadManager {
/// Check if a new download can be started based on concurrent limit
pub fn can_start_download(&self) -> bool {
let active = self.active_downloads.lock().unwrap();
let active = self.active_downloads.lock_safe();
active.len() < self.max_concurrent
}
/// Get the number of currently active downloads
pub fn active_count(&self) -> usize {
self.active_downloads.lock().unwrap().len()
self.active_downloads.lock_safe().len()
}
/// Register a download as active
pub fn register_download(&self, download_id: i64) -> bool {
let mut active = self.active_downloads.lock().unwrap();
let mut active = self.active_downloads.lock_safe();
if active.len() >= self.max_concurrent {
return false;
}
@@ -65,7 +66,7 @@ impl DownloadManager {
/// Unregister a download when it completes or fails
pub fn unregister_download(&self, download_id: i64) {
let mut active = self.active_downloads.lock().unwrap();
let mut active = self.active_downloads.lock_safe();
active.remove(&download_id);
}
@@ -76,7 +77,7 @@ impl DownloadManager {
}
/// Information about a download
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadInfo {
pub id: i64,
@@ -119,7 +120,6 @@ pub struct DownloadTask {
#[cfg(test)]
mod tests {
use super::*;
use crate::player::MediaType;
#[test]
fn test_download_manager_set_max_concurrent() {
+13 -5
View File
@@ -21,6 +21,7 @@ impl DownloadWorker {
pub fn new() -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout
.https_only(true)
.build()
.expect("Failed to create HTTP client");
@@ -31,14 +32,18 @@ impl DownloadWorker {
}
/// Download a file with retry logic and progress tracking
pub async fn download(
pub async fn download<F>(
&self,
task: &DownloadTask,
) -> Result<DownloadResult, DownloadError> {
on_progress: F,
) -> Result<DownloadResult, DownloadError>
where
F: Fn(u64, Option<u64>) + Send + Sync,
{
let mut retries = 0;
loop {
match self.try_download(task).await {
match self.try_download(task, &on_progress).await {
Ok(result) => return Ok(result),
Err(e) if retries < self.max_retries && e.is_retryable() => {
retries += 1;
@@ -55,7 +60,10 @@ impl DownloadWorker {
}
/// Attempt a single download
async fn try_download(&self, task: &DownloadTask) -> 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,
{
// Create parent directories
if let Some(parent) = task.target_path.parent() {
fs::create_dir_all(parent)
@@ -129,7 +137,7 @@ impl DownloadWorker {
|| downloaded % (1024 * 1024) == 0
{
last_progress_emit = std::time::Instant::now();
// Progress events will be emitted by the manager
on_progress(downloaded, _total_bytes);
}
}
+99 -33
View File
@@ -1,3 +1,5 @@
//! TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011
use log::{debug, error, info};
use reqwest::Client;
use serde::Deserialize;
@@ -20,6 +22,7 @@ impl JellyfinClient {
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.https_only(true)
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
@@ -221,20 +224,20 @@ impl JellyfinClient {
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id, item_ids.len(), start_index);
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
// Build URL with query parameters (Jellyfin expects PascalCase query params)
let mut url = format!(
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
"{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
self.config.server_url, session_id, start_index
);
// Add item IDs as repeated query parameters
for item_id in &item_ids {
url.push_str(&format!("&itemIds={}", item_id));
url.push_str(&format!("&ItemIds={}", item_id));
}
// Add start position if provided
if let Some(ticks) = start_position_ticks {
url.push_str(&format!("&startPositionTicks={}", ticks));
url.push_str(&format!("&StartPositionTicks={}", ticks));
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
}
@@ -281,22 +284,61 @@ impl JellyfinClient {
}
/// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek(
&self,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SeekRequest {
seek_position_ticks: i64,
let url = format!(
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
self.config.server_url, session_id, position_ticks
);
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
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 request = SeekRequest {
seek_position_ticks: position_ticks,
};
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
Ok(())
}
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
/// Send a full GeneralCommand to a remote session.
/// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments.
/// This is required for commands that need arguments (e.g. SetVolume).
async fn send_general_command(
&self,
session_id: &str,
command_name: &str,
arguments: Option<serde_json::Value>,
) -> Result<(), String> {
let mut payload = serde_json::json!({
"Name": command_name,
});
if let Some(args) = arguments {
payload["Arguments"] = args;
}
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
}
/// Set volume on a remote session
@@ -305,18 +347,10 @@ impl JellyfinClient {
session_id: String,
volume: i32,
) -> Result<(), String> {
let payload = serde_json::json!({
"Arguments": {
"Volume": volume.to_string()
}
});
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
self.post(
&format!("/Sessions/{}/Command/SetVolume", session_id),
&payload
self.send_general_command(
&session_id,
"SetVolume",
Some(serde_json::json!({ "Volume": volume.to_string() })),
).await
}
@@ -327,9 +361,10 @@ impl JellyfinClient {
) -> Result<(), String> {
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
self.post(
&format!("/Sessions/{}/Command/ToggleMute", session_id),
&serde_json::json!({})
self.send_general_command(
&session_id,
"ToggleMute",
None,
).await
}
@@ -359,72 +394,103 @@ fn default_true() -> bool {
}
/// Session information from Jellyfin
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
#[serde(default)]
#[serde(alias = "Id")]
pub id: Option<String>,
#[serde(default)]
#[serde(alias = "UserId")]
pub user_id: Option<String>,
#[serde(default)]
#[serde(alias = "UserName")]
pub user_name: Option<String>,
#[serde(default)]
#[serde(alias = "Client")]
pub client: Option<String>,
#[serde(default)]
#[serde(alias = "DeviceName")]
pub device_name: Option<String>,
#[serde(default)]
#[serde(alias = "DeviceId")]
pub device_id: Option<String>,
#[serde(default)]
#[serde(alias = "ApplicationVersion")]
pub application_version: Option<String>,
#[serde(default)]
#[serde(alias = "IsActive")]
pub is_active: Option<bool>,
#[serde(default)]
#[serde(alias = "SupportsMediaControl")]
pub supports_media_control: Option<bool>,
#[serde(default = "default_true")]
#[serde(alias = "SupportsRemoteControl")]
pub supports_remote_control: bool,
#[serde(default)]
#[serde(alias = "NowPlayingItem")]
pub now_playing_item: Option<NowPlayingItem>,
#[serde(default)]
#[serde(alias = "PlayState")]
pub play_state: Option<PlayState>,
#[serde(default)]
#[serde(alias = "PlayableMediaTypes")]
pub playable_media_types: Option<Vec<String>>,
#[serde(default)]
#[serde(alias = "SupportedCommands")]
pub supported_commands: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NowPlayingItem {
#[serde(alias = "Id")]
pub id: Option<String>,
#[serde(alias = "Name")]
pub name: Option<String>,
#[serde(alias = "RunTimeTicks")]
pub run_time_ticks: Option<i64>,
#[serde(alias = "Album")]
pub album: Option<String>,
#[serde(alias = "AlbumId")]
pub album_id: Option<String>,
#[serde(alias = "AlbumArtist")]
pub album_artist: Option<String>,
#[serde(alias = "Artists")]
pub artists: Option<Vec<String>>,
#[serde(alias = "ImageTags")]
pub image_tags: Option<std::collections::HashMap<String, String>>,
#[serde(alias = "PrimaryImageTag")]
pub primary_image_tag: Option<String>,
#[serde(alias = "AlbumPrimaryImageTag")]
pub album_primary_image_tag: Option<String>,
#[serde(rename = "Type")]
pub item_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayState {
#[serde(default)]
#[serde(alias = "PositionTicks")]
pub position_ticks: Option<i64>,
#[serde(default)]
#[serde(alias = "CanSeek")]
pub can_seek: Option<bool>,
#[serde(default)]
#[serde(alias = "IsPaused")]
pub is_paused: Option<bool>,
#[serde(default)]
#[serde(alias = "IsMuted")]
pub is_muted: Option<bool>,
#[serde(default)]
#[serde(alias = "VolumeLevel")]
pub volume_level: Option<i32>,
#[serde(default)]
#[serde(alias = "RepeatMode")]
pub repeat_mode: Option<String>,
#[serde(default)]
#[serde(alias = "ShuffleMode")]
pub shuffle_mode: Option<String>,
}
+3 -2
View File
@@ -5,8 +5,8 @@ use std::time::Duration;
const APP_NAME: &str = "JellyTau";
const APP_VERSION: &str = "0.1.0";
// Default timeout for requests (10 seconds)
const DEFAULT_TIMEOUT_MS: u64 = 10000;
// Default timeout for requests (30 seconds - large library queries can be slow)
const DEFAULT_TIMEOUT_MS: u64 = 30000;
// Retry configuration - matches TypeScript exactly
const DEFAULT_MAX_RETRIES: u32 = 3;
@@ -49,6 +49,7 @@ impl HttpClient {
pub fn new(config: HttpConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.https_only(true)
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
+234
View File
@@ -41,3 +41,237 @@ pub struct PlaybackProgressRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub play_session_id: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_jellyfin_config_creation() {
let config = JellyfinConfig {
server_url: "https://jellyfin.example.com".to_string(),
access_token: "token-123".to_string(),
device_id: "device-456".to_string(),
};
assert_eq!(config.server_url, "https://jellyfin.example.com");
assert_eq!(config.access_token, "token-123");
assert_eq!(config.device_id, "device-456");
}
#[test]
fn test_jellyfin_config_clone() {
let config = JellyfinConfig {
server_url: "https://server.local".to_string(),
access_token: "token-abc".to_string(),
device_id: "device-xyz".to_string(),
};
let cloned = config.clone();
assert_eq!(config.server_url, cloned.server_url);
assert_eq!(config.access_token, cloned.access_token);
assert_eq!(config.device_id, cloned.device_id);
}
#[test]
fn test_playback_start_request_serialization() {
let request = PlaybackStartRequest {
item_id: "item-123".to_string(),
position_ticks: 0,
play_session_id: Some("session-456".to_string()),
play_command: "PlayNow".to_string(),
is_paused: false,
};
let json = serde_json::to_string(&request);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("item-123"));
assert!(serialized.contains("PlayNow"));
}
#[test]
fn test_playback_start_request_without_session() {
let request = PlaybackStartRequest {
item_id: "item-789".to_string(),
position_ticks: 1_000_000,
play_session_id: None,
play_command: "Resume".to_string(),
is_paused: true,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("true"));
assert!(!json.contains("playSessionId"));
}
#[test]
fn test_playback_start_request_pascal_case() {
let request = PlaybackStartRequest {
item_id: "item-1".to_string(),
position_ticks: 100,
play_session_id: Some("session-1".to_string()),
play_command: "Play".to_string(),
is_paused: false,
};
let json = serde_json::to_string(&request).unwrap();
// Verify PascalCase serialization
assert!(json.contains("ItemId"));
assert!(json.contains("PositionTicks"));
assert!(json.contains("PlaySessionId"));
assert!(json.contains("PlayCommand"));
assert!(json.contains("IsPaused"));
}
#[test]
fn test_playback_stopped_request_serialization() {
let request = PlaybackStoppedRequest {
item_id: "item-555".to_string(),
position_ticks: 5_000_000,
play_session_id: Some("session-789".to_string()),
};
let json = serde_json::to_string(&request);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("item-555"));
assert!(serialized.contains("5000000"));
}
#[test]
fn test_playback_stopped_request_without_session() {
let request = PlaybackStoppedRequest {
item_id: "item-000".to_string(),
position_ticks: 0,
play_session_id: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("item-000"));
assert!(!json.contains("playSessionId"));
}
#[test]
fn test_playback_stopped_request_pascal_case() {
let request = PlaybackStoppedRequest {
item_id: "i1".to_string(),
position_ticks: 500,
play_session_id: Some("s1".to_string()),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("ItemId"));
assert!(json.contains("PositionTicks"));
assert!(json.contains("PlaySessionId"));
}
#[test]
fn test_playback_progress_request_serialization() {
let request = PlaybackProgressRequest {
item_id: "item-999".to_string(),
position_ticks: 150_000_000,
is_paused: false,
play_session_id: Some("session-111".to_string()),
};
let json = serde_json::to_string(&request);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("item-999"));
assert!(serialized.contains("150000000"));
assert!(serialized.contains("false"));
}
#[test]
fn test_playback_progress_request_paused() {
let request = PlaybackProgressRequest {
item_id: "item-paused".to_string(),
position_ticks: 75_000_000,
is_paused: true,
play_session_id: None,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("true"));
assert!(json.contains("item-paused"));
}
#[test]
fn test_playback_progress_request_pascal_case() {
let request = PlaybackProgressRequest {
item_id: "i2".to_string(),
position_ticks: 200,
is_paused: true,
play_session_id: Some("s2".to_string()),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("ItemId"));
assert!(json.contains("PositionTicks"));
assert!(json.contains("IsPaused"));
assert!(json.contains("PlaySessionId"));
}
#[test]
fn test_playback_start_request_various_position_values() {
let positions = vec![0, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000];
for pos in positions {
let request = PlaybackStartRequest {
item_id: "item-test".to_string(),
position_ticks: pos,
play_session_id: None,
play_command: "Play".to_string(),
is_paused: false,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("item-test"));
assert!(json.contains(&pos.to_string()));
}
}
#[test]
fn test_jellyfin_config_debug() {
let config = JellyfinConfig {
server_url: "https://debug.example.com".to_string(),
access_token: "token-debug".to_string(),
device_id: "device-debug".to_string(),
};
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("JellyfinConfig"));
assert!(debug_str.contains("https://debug.example.com"));
}
#[test]
fn test_playback_requests_creation_patterns() {
// Test various creation patterns
let start = PlaybackStartRequest {
item_id: "i1".to_string(),
position_ticks: 0,
play_session_id: None,
play_command: "PlayNow".to_string(),
is_paused: false,
};
let stopped = PlaybackStoppedRequest {
item_id: "i1".to_string(),
position_ticks: 1_000_000,
play_session_id: None,
};
let progress = PlaybackProgressRequest {
item_id: "i1".to_string(),
position_ticks: 500_000,
is_paused: false,
play_session_id: None,
};
// All should serialize successfully
assert!(serde_json::to_string(&start).is_ok());
assert!(serde_json::to_string(&stopped).is_ok());
assert!(serde_json::to_string(&progress).is_ok());
}
}
+322 -229
View File
@@ -12,11 +12,12 @@ mod session_poller;
pub mod settings;
mod storage;
mod thumbnail;
mod utils;
pub mod utils;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use tauri::Manager;
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use log::{error, info};
#[cfg(target_os = "android")]
use log::warn;
@@ -27,7 +28,7 @@ use commands::{
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,
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,
@@ -95,11 +96,16 @@ use commands::{
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, 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_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,
@@ -117,8 +123,9 @@ use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend fallback for platforms without native backends (not Linux or Android)
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// 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
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend;
#[cfg(target_os = "linux")]
@@ -222,13 +229,39 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
}
}
/// Payload emitted to the frontend when a native player backend fails to
/// initialize and the app falls back to a no-op backend.
#[derive(Clone, serde::Serialize)]
struct BackendInitError {
platform: &'static str,
backend: &'static str,
message: String,
}
/// Log a backend-initialization failure and notify the frontend, so the UI can
/// surface "playback unavailable" instead of the app hard-crashing.
fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
error!(
"[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
backend, message
);
let _ = app_handle.emit(
"backend-init-failed",
BackendInitError {
platform: std::env::consts::OS,
backend,
message,
},
);
}
/// Create the appropriate player backend for the current platform.
fn create_player_backend(
app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
position_throttler: Arc<playback_reporting::EventThrottler>,
) -> Box<dyn PlayerBackend> {
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
#[cfg(target_os = "android")]
{
@@ -252,17 +285,21 @@ fn create_player_backend(
return Box::new(backend);
}
Err(e) => {
panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
// Degrade gracefully instead of crashing the app.
emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
return Box::new(NullBackend::new());
}
}
}
@@ -295,7 +332,11 @@ fn create_player_backend(
error!("\nAudio playback will NOT work until this is fixed.");
error!("========================================\n");
panic!("Cannot start application: MPV backend initialization failed. See error message above.");
// Degrade gracefully: launch with a no-op backend so the user can
// still browse the library and manage downloads, and the frontend
// can show a "playback unavailable" notice via this event.
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
return Box::new(NullBackend::new());
}
}
}
@@ -308,6 +349,249 @@ fn create_player_backend(
}
}
/// Construct the tauri-specta command builder. Shared by `run()` and the
/// bindings-export test so the TypeScript bindings always match the handler.
fn specta_builder() -> Builder<tauri::Wry> {
Builder::<tauri::Wry>::new()
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
player_play_queue,
player_play_album_track,
player_play_tracks,
player_play,
player_pause,
player_toggle,
player_stop,
player_next,
player_previous,
player_seek,
player_seek_video,
player_set_volume,
player_toggle_mute,
player_set_audio_track,
player_switch_audio_track,
player_set_subtitle_track,
player_toggle_shuffle,
player_cycle_repeat,
player_get_status,
player_get_queue,
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,
player_set_audio_settings,
player_get_audio_settings,
player_set_video_settings,
player_get_video_settings,
// 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,
// 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,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
// Playback mode commands
playback_mode_get_current,
playback_mode_set,
playback_mode_is_transferring,
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
playback_report_start,
playback_report_progress,
playback_report_stopped,
playback_mark_played,
// 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_init,
storage_get_path,
storage_get_size,
storage_get_security_status,
storage_save_server,
storage_get_servers,
storage_delete_server,
storage_save_user,
storage_get_users,
storage_set_active_user,
storage_get_active_user,
storage_get_active_session,
storage_get_access_token,
storage_delete_user,
// Playback progress commands
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
// Download commands
download_item,
download_item_and_start,
download_album,
download_video,
download_series,
download_season,
get_downloads,
pause_download,
resume_download,
cancel_download,
delete_download,
delete_all_downloads,
delete_album_downloads,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
mark_download_failed,
start_download,
enqueue_download,
enqueue_video_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,
// Pinning commands
pin_item,
unpin_item,
is_item_pinned,
// Offline commands
offline_is_available,
offline_get_items,
offline_search,
// 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_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_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,
])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
@@ -315,10 +599,22 @@ pub fn run() {
.filter_level(log::LevelFilter::Info)
.init();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
.invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -558,224 +854,21 @@ pub fn run() {
info!("[INIT] Application setup completed successfully");
Ok(())
})
.invoke_handler(tauri::generate_handler![
// Player commands
player_play_item,
player_play_queue,
player_play_album_track,
player_play_tracks,
player_play,
player_pause,
player_toggle,
player_stop,
player_next,
player_previous,
player_seek,
player_seek_video,
player_set_volume,
player_toggle_mute,
player_set_audio_track,
player_switch_audio_track,
player_set_subtitle_track,
player_toggle_shuffle,
player_cycle_repeat,
player_get_status,
player_get_queue,
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,
player_set_audio_settings,
player_get_audio_settings,
player_set_video_settings,
player_get_video_settings,
// 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,
// 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,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
// Playback mode commands
playback_mode_get_current,
playback_mode_set,
playback_mode_is_transferring,
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
playback_report_start,
playback_report_progress,
playback_report_stopped,
playback_mark_played,
// 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_init,
storage_get_path,
storage_get_size,
storage_get_security_status,
storage_save_server,
storage_get_servers,
storage_delete_server,
storage_save_user,
storage_get_users,
storage_set_active_user,
storage_get_active_user,
storage_get_active_session,
storage_get_access_token,
storage_delete_user,
// Playback progress commands
storage_update_playback_progress,
storage_update_playback_context,
storage_mark_played,
storage_get_playback_progress,
storage_mark_synced,
storage_toggle_favorite,
// Download commands
download_item,
download_item_and_start,
download_album,
download_video,
download_series,
download_season,
get_downloads,
pause_download,
resume_download,
cancel_download,
delete_download,
delete_all_downloads,
delete_album_downloads,
clear_stale_downloads,
get_download_storage_stats,
mark_download_completed,
mark_download_failed,
start_download,
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,
// Pinning commands
pin_item,
unpin_item,
is_item_pinned,
// Offline commands
offline_is_available,
offline_get_items,
offline_search,
// 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_get_latest_items,
repository_get_resume_items,
repository_get_next_up_episodes,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_genres,
repository_search,
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
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,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
convert_ticks_to_seconds,
calc_progress,
convert_percent_to_volume,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod specta_bindings {
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
#[test]
fn export_typescript_bindings() {
super::specta_builder()
.export(
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
"../src/lib/api/bindings.ts",
)
.expect("failed to export typescript bindings");
}
}
+148 -18
View File
@@ -1,3 +1,4 @@
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::{
@@ -11,7 +12,7 @@ use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlaybackMode {
Local,
@@ -19,6 +20,28 @@ pub enum PlaybackMode {
Idle,
}
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -43,13 +66,13 @@ impl PlaybackModeManager {
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read().unwrap().clone()
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write().unwrap();
let mut current = self.current_mode.write_safe();
*current = mode;
}
@@ -158,7 +181,11 @@ impl PlaybackModeManager {
}
/// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id);
log::info!(
@@ -172,7 +199,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await;
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -180,12 +207,24 @@ impl PlaybackModeManager {
result
}
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
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
// position. Capture it so we can resume there and stop it afterwards.
let previous_remote_session = match self.get_mode() {
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
_ => None,
};
// Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = {
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await;
@@ -193,8 +232,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Player controller lock acquired");
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
let state = player.state();
let queue = queue_arc.lock_safe();
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
@@ -209,7 +247,19 @@ impl PlaybackModeManager {
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0);
// Prefer the frontend-supplied position when available. The backend
// position is unreliable as a transfer source: on Linux, *video* plays
// in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() is always 0; only the frontend knows the
// true position. We fall back to the live backend position (correct for
// Linux audio via MPV) when the frontend doesn't pass one.
let position = match position_override {
Some(p) => {
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
p
}
None => player.position(),
};
let context = queue.context().clone();
log::info!(
@@ -271,12 +321,48 @@ impl PlaybackModeManager {
}
};
// Calculate position in ticks
let start_position_ticks = if position_seconds > 0.5 {
Some((position_seconds * 10_000_000.0) as i64)
} else {
None
};
// Remote -> remote switch: take the current track and position from the
// session we're leaving, since the local player is idle and reports 0.
if let Some(ref prev_session_id) = previous_remote_session {
log::info!(
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
prev_session_id
);
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) {
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
position_seconds
);
}
// 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(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
now_id,
idx
);
current_index = idx;
} else {
log::warn!(
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
now_id,
current_index
);
}
}
}
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),
}
}
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
@@ -391,6 +477,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string());
}
// Resume at the right position. We send StartPositionTicks in the play
// command above, but some Jellyfin client/server combinations ignore it
// and start from 0. Now that the track is confirmed loaded, issue an
// explicit seek as well (mirrors how the local resume path works). This
// is the reliable mechanism; StartPositionTicks is best-effort.
if let Some(ticks) = start_position_ticks {
log::info!(
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
ticks
);
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
// Non-fatal: the track is already playing, just not at the
// resume point. Log and continue rather than failing the transfer.
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
}
}
// Remote -> remote switch: stop the session we just left so we don't end
// 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);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
}
}
// Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{
@@ -399,7 +512,7 @@ impl PlaybackModeManager {
// Log queue state BEFORE stop
{
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
let queue = queue_arc.lock_safe();
info!(
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
queue.items().len(),
@@ -412,7 +525,7 @@ impl PlaybackModeManager {
// Log queue state AFTER stop (should be unchanged)
{
let queue_arc = player.queue();
let queue = queue_arc.lock().unwrap();
let queue = queue_arc.lock_safe();
info!(
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
queue.items().len(),
@@ -576,6 +689,23 @@ mod tests {
);
}
/// 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).
#[test]
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));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType};
+5 -4
View File
@@ -5,6 +5,7 @@
#![allow(dead_code)]
use crate::utils::lock::MutexSafe;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -34,7 +35,7 @@ impl EventThrottler {
/// Checks if enough time has elapsed since the last report for this item
pub fn should_report(&self, item_id: &str) -> bool {
let last_times = self.last_report_time.lock().unwrap();
let last_times = self.last_report_time.lock_safe();
if let Some(last_time) = last_times.get(item_id) {
let elapsed = last_time.elapsed();
@@ -54,7 +55,7 @@ impl EventThrottler {
/// Marks the item as reported at the current time
pub fn mark_reported(&self, item_id: &str) {
let mut last_times = self.last_report_time.lock().unwrap();
let mut last_times = self.last_report_time.lock_safe();
last_times.insert(item_id.to_string(), Instant::now());
log::debug!(
@@ -66,14 +67,14 @@ impl EventThrottler {
/// Clears all tracked report times
pub fn clear(&self) {
let mut last_times = self.last_report_time.lock().unwrap();
let mut last_times = self.last_report_time.lock_safe();
last_times.clear();
log::debug!("[EventThrottler] Cleared all tracked report times");
}
/// Removes a specific item from tracking
pub fn clear_item(&self, item_id: &str) {
let mut last_times = self.last_report_time.lock().unwrap();
let mut last_times = self.last_report_time.lock_safe();
last_times.remove(item_id);
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
}
+20 -14
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
@@ -303,7 +304,7 @@ impl PlayerBackend for ExoPlayerBackend {
// Update local state
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.current_media = Some(media.clone());
state.state = PlayerState::Loading {
media: media.clone(),
@@ -430,7 +431,7 @@ impl PlayerBackend for ExoPlayerBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.state = PlayerState::Idle;
state.is_loaded = false;
state.current_media = None;
@@ -479,24 +480,24 @@ impl PlayerBackend for ExoPlayerBackend {
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
self.shared_state.lock().unwrap().volume = clamped;
self.shared_state.lock_safe().volume = clamped;
Ok(())
}
fn position(&self) -> f64 {
self.shared_state.lock().unwrap().position
self.shared_state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.shared_state.lock().unwrap().duration
self.shared_state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.shared_state.lock().unwrap().state.clone()
self.shared_state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.shared_state.lock().unwrap().volume
self.shared_state.lock_safe().volume
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
@@ -564,7 +565,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock().unwrap();
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
@@ -606,7 +607,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock().unwrap();
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
@@ -651,7 +652,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock().unwrap();
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
@@ -677,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
@@ -692,7 +698,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@@ -702,7 +708,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@@ -811,7 +817,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock().unwrap().volume = volume;
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {
+27 -2
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::repository::types::MediaItem;
/// Autoplay decision result - determines what happens after playback ends
#[derive(Debug, Clone, Serialize)]
#[derive(specta::Type, Debug, Clone, Serialize)]
#[serde(tag = "action", rename_all = "camelCase")]
pub enum AutoplayDecision {
/// Stop playback (no next item or timer expired)
@@ -21,13 +21,16 @@ pub enum AutoplayDecision {
}
/// Autoplay settings (controls next episode behavior)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoplaySettings {
/// Whether autoplay is enabled for next episodes
pub enabled: bool,
/// Countdown duration in seconds before auto-playing next episode
pub countdown_seconds: u32,
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
#[serde(default)]
pub max_episodes: u32,
}
impl Default for AutoplaySettings {
@@ -35,6 +38,7 @@ impl Default for AutoplaySettings {
Self {
enabled: true,
countdown_seconds: 10,
max_episodes: 0,
}
}
}
@@ -56,6 +60,24 @@ mod tests {
let settings = AutoplaySettings::default();
assert!(settings.enabled);
assert_eq!(settings.countdown_seconds, 10);
assert_eq!(settings.max_episodes, 0);
}
#[test]
fn test_autoplay_settings_backward_compat() {
// Deserialize old JSON without max_episodes field
let json = r#"{"enabled":true,"countdownSeconds":15}"#;
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled);
assert_eq!(settings.countdown_seconds, 15);
assert_eq!(settings.max_episodes, 0); // defaults to 0 (unlimited)
}
#[test]
fn test_autoplay_settings_with_max_episodes() {
let json = r#"{"enabled":true,"countdownSeconds":10,"maxEpisodes":5}"#;
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
assert_eq!(settings.max_episodes, 5);
}
#[test]
@@ -63,6 +85,7 @@ mod tests {
let settings = AutoplaySettings {
enabled: true,
countdown_seconds: 2, // Too short
max_episodes: 0,
}
.with_validated_countdown();
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
@@ -70,6 +93,7 @@ mod tests {
let settings = AutoplaySettings {
enabled: true,
countdown_seconds: 60, // Too long
max_episodes: 0,
}
.with_validated_countdown();
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
@@ -77,6 +101,7 @@ mod tests {
let settings = AutoplaySettings {
enabled: true,
countdown_seconds: 15, // Valid
max_episodes: 0,
}
.with_validated_countdown();
assert_eq!(settings.countdown_seconds, 15); // Unchanged
+17 -12
View File
@@ -5,10 +5,13 @@
//!
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::error;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode};
@@ -18,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
@@ -111,9 +121,6 @@ pub enum PlayerStatusEvent {
},
}
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend.
///
/// This abstraction allows backends to emit events without depending
@@ -139,7 +146,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
// Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e);
}
}
@@ -168,17 +177,13 @@ mod tests {
}
pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock().unwrap().clone()
}
pub fn clear(&self) {
self.events.lock().unwrap().clear();
self.events.lock_safe().clone()
}
}
impl PlayerEventEmitter for TestEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
self.events.lock_safe().push(event);
}
}
+394 -5
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
/// Context for the current queue - where did the queue items come from?
/// This is used for remote playback transfer to send album/playlist context.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum QueueContext {
/// Playing from a specific album
@@ -23,7 +23,7 @@ pub enum QueueContext {
}
/// Represents a subtitle track
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SubtitleTrack {
/// Stream index in the media source
pub index: i32,
@@ -40,8 +40,9 @@ pub struct SubtitleTrack {
/// Represents a media item that can be played
///
/// TRACES: UR-003, UR-004 | DR-002
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[specta(rename = "PlayerMediaItem")]
pub struct MediaItem {
/// Unique identifier
pub id: String,
@@ -106,7 +107,7 @@ pub struct MediaItem {
pub server_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaType {
Audio,
@@ -114,8 +115,9 @@ pub enum MediaType {
}
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
#[specta(rename = "PlayerMediaSource")]
pub enum MediaSource {
/// Streaming from Jellyfin server
Remote {
@@ -156,3 +158,390 @@ impl MediaItem {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_queue_context_album() {
let context = QueueContext::Album {
album_id: "album-123".to_string(),
album_name: "Test Album".to_string(),
};
assert!(matches!(context, QueueContext::Album { .. }));
}
#[test]
fn test_queue_context_playlist() {
let context = QueueContext::Playlist {
playlist_id: "playlist-456".to_string(),
playlist_name: "Test Playlist".to_string(),
};
assert!(matches!(context, QueueContext::Playlist { .. }));
}
#[test]
fn test_queue_context_custom() {
let context = QueueContext::Custom;
assert!(matches!(context, QueueContext::Custom));
}
#[test]
fn test_queue_context_serialization() {
let context = QueueContext::Album {
album_id: "alb-001".to_string(),
album_name: "Album 001".to_string(),
};
let json = serde_json::to_string(&context);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("album"));
}
#[test]
fn test_queue_context_default() {
let context = QueueContext::default();
assert!(matches!(context, QueueContext::Custom));
}
#[test]
fn test_queue_context_clone() {
let context = QueueContext::Album {
album_id: "clone-alb".to_string(),
album_name: "Clone Album".to_string(),
};
let cloned = context.clone();
assert_eq!(context, cloned);
}
#[test]
fn test_subtitle_track_creation() {
let track = SubtitleTrack {
index: 0,
url: "https://example.com/subs.vtt".to_string(),
language: Some("eng".to_string()),
label: Some("English".to_string()),
mime_type: "text/vtt".to_string(),
};
assert_eq!(track.index, 0);
assert_eq!(track.language, Some("eng".to_string()));
}
#[test]
fn test_subtitle_track_without_language() {
let track = SubtitleTrack {
index: 1,
url: "https://example.com/subs.srt".to_string(),
language: None,
label: Some("Subtitles".to_string()),
mime_type: "application/x-subrip".to_string(),
};
assert!(track.language.is_none());
assert!(track.label.is_some());
}
#[test]
fn test_subtitle_track_serialization() {
let track = SubtitleTrack {
index: 0,
url: "url.vtt".to_string(),
language: Some("eng".to_string()),
label: None,
mime_type: "text/vtt".to_string(),
};
let json = serde_json::to_string(&track);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("0"));
assert!(serialized.contains("eng"));
}
#[test]
fn test_media_type_audio() {
let media_type = MediaType::Audio;
let json = serde_json::to_string(&media_type).unwrap();
assert!(json.contains("audio"));
}
#[test]
fn test_media_type_video() {
let media_type = MediaType::Video;
let json = serde_json::to_string(&media_type).unwrap();
assert!(json.contains("video"));
}
#[test]
fn test_media_source_remote() {
let source = MediaSource::Remote {
stream_url: "https://server.com/video.mp4".to_string(),
jellyfin_item_id: "item-123".to_string(),
};
assert!(matches!(source, MediaSource::Remote { .. }));
}
#[test]
fn test_media_source_local() {
let source = MediaSource::Local {
file_path: PathBuf::from("/path/to/video.mp4"),
jellyfin_item_id: Some("item-456".to_string()),
};
assert!(matches!(source, MediaSource::Local { .. }));
}
#[test]
fn test_media_source_local_without_jellyfin_id() {
let source = MediaSource::Local {
file_path: PathBuf::from("/downloads/audio.mp3"),
jellyfin_item_id: None,
};
assert!(matches!(source, MediaSource::Local { .. }));
}
#[test]
fn test_media_source_direct_url() {
let source = MediaSource::DirectUrl {
url: "https://external.com/stream".to_string(),
};
assert!(matches!(source, MediaSource::DirectUrl { .. }));
}
#[test]
fn test_media_source_serialization() {
let source = MediaSource::DirectUrl {
url: "https://example.com/stream".to_string(),
};
let json = serde_json::to_string(&source);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("directurl"));
}
#[test]
fn test_media_item_creation_minimal() {
let item = MediaItem {
id: "item-1".to_string(),
title: "Test Item".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: "https://example.com/video".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
assert_eq!(item.id, "item-1");
assert_eq!(item.title, "Test Item");
assert!(!item.needs_transcoding);
}
#[test]
fn test_media_item_jellyfin_id() {
let item = MediaItem {
id: "item-2".to_string(),
title: "Test".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: "https://server/stream".to_string(),
jellyfin_item_id: "jf-id-123".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
}
#[test]
fn test_media_item_jellyfin_id_local() {
let item = MediaItem {
id: "item-3".to_string(),
title: "Local".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::Local {
file_path: PathBuf::from("/local/video.mp4"),
jellyfin_item_id: Some("jf-local".to_string()),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
assert_eq!(item.jellyfin_id(), Some("jf-local"));
}
#[test]
fn test_media_item_jellyfin_id_direct_url() {
let item = MediaItem {
id: "item-4".to_string(),
title: "Direct".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: "https://external.com/media".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
assert_eq!(item.jellyfin_id(), None);
}
#[test]
fn test_media_item_with_subtitles() {
let sub = SubtitleTrack {
index: 0,
url: "subs.vtt".to_string(),
language: Some("eng".to_string()),
label: Some("English".to_string()),
mime_type: "text/vtt".to_string(),
};
let item = MediaItem {
id: "item-subs".to_string(),
title: "With Subs".to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: "video.mp4".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![sub],
series_id: None,
server_id: None,
};
assert_eq!(item.subtitles.len(), 1);
assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
}
#[test]
fn test_media_item_serialization() {
let item = MediaItem {
id: "serial-item".to_string(),
title: "Serial Test".to_string(),
name: Some("Name".to_string()),
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: Some("Movie".to_string()),
playlist_id: None,
duration: Some(120.0),
artwork_url: None,
media_type: MediaType::Video,
source: MediaSource::DirectUrl {
url: "https://example.com/movie.mp4".to_string(),
},
video_codec: Some("h264".to_string()),
needs_transcoding: false,
video_width: Some(1920),
video_height: Some(1080),
subtitles: vec![],
series_id: None,
server_id: None,
};
let json = serde_json::to_string(&item);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("serial-item"));
assert!(serialized.contains("Serial Test"));
}
}
+385 -98
View File
@@ -7,6 +7,7 @@ pub mod backend;
pub mod events;
pub mod media;
pub mod queue;
pub mod seek;
pub mod session;
pub mod sleep_timer;
pub mod state;
@@ -27,6 +28,7 @@ pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState};
@@ -44,7 +46,8 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
};
use log::{debug, warn};
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
@@ -86,6 +89,9 @@ pub struct PlayerController {
// End reason tracking for autoplay decision making
end_reason: Arc<Mutex<Option<EndReason>>>,
// Auto-play episode counter (session-based, resets on manual play)
autoplay_episode_count: Arc<Mutex<u32>>,
}
impl PlayerController {
@@ -107,6 +113,7 @@ impl PlayerController {
playback_reporter,
position_throttler,
end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)),
};
// Start background timer thread for sleep timer countdown
@@ -117,7 +124,7 @@ impl PlayerController {
/// Configure the Jellyfin API client for automatic playback reporting
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock().unwrap();
let mut jellyfin = self.jellyfin_client.lock_safe();
*jellyfin = client;
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
}
@@ -153,22 +160,49 @@ impl PlayerController {
/// Set the end reason for the next playback end event
fn set_end_reason(&self, reason: EndReason) {
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
*self.end_reason.lock().unwrap() = Some(reason);
*self.end_reason.lock_safe() = Some(reason);
}
/// Get and clear the current end reason
fn take_end_reason(&self) -> Option<EndReason> {
self.end_reason.lock().unwrap().take()
self.end_reason.lock_safe().take()
}
/// Increment autoplay episode counter. Returns true if limit is reached.
fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock_safe().max_episodes;
if max == 0 {
// Unlimited
return false;
}
let mut count = self.autoplay_episode_count.lock_safe();
*count += 1;
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
*count >= max
}
/// Reset autoplay episode counter (called on manual play actions)
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);
}
*count = 0;
}
/// Load and play a single item (also sets the queue to contain only this item)
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] play_item: {}", item.title);
// Reset autoplay counter on manual play
self.reset_autoplay_count();
// Update queue with this single item
{
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item.clone()], 0);
}
@@ -178,6 +212,23 @@ impl PlayerController {
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// 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);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -186,7 +237,7 @@ impl PlayerController {
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
self.set_end_reason(EndReason::NewTrackLoaded);
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.load(item)?;
backend.play()?;
drop(backend);
@@ -260,37 +311,86 @@ impl PlayerController {
/// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start
self.reset_autoplay_count();
{
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
}
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock().unwrap().current().cloned() {
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
}
Ok(())
}
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
backend.pause()
} else {
@@ -305,16 +405,16 @@ impl PlayerController {
// Get current playback info before stopping
let jellyfin_id = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
};
let position_ticks = {
let backend = self.backend.lock().unwrap();
let backend = self.backend.lock_safe();
(backend.position() * 10_000_000.0) as i64
};
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.stop()?;
drop(backend);
@@ -374,8 +474,11 @@ impl PlayerController {
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
/// from triggering when the current track's EndFile event fires
pub fn next(&self) -> Result<(), PlayerError> {
// Reset autoplay counter on manual skip
self.reset_autoplay_count();
let next_item = {
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.next().cloned()
};
@@ -394,9 +497,11 @@ impl PlayerController {
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
/// from triggering when the current track's EndFile event fires
pub fn previous(&self) -> Result<(), PlayerError> {
// Reset autoplay counter on manual skip
self.reset_autoplay_count();
// If we're more than 3 seconds in, restart current track
{
let backend = self.backend.lock().unwrap();
let backend = self.backend.lock_safe();
if backend.position() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend);
@@ -405,7 +510,7 @@ impl PlayerController {
}
let prev_item = {
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.previous().cloned()
};
@@ -420,40 +525,40 @@ impl PlayerController {
/// Seek to a position in seconds
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.seek(position)
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock().unwrap().set_volume(volume)
self.backend.lock_safe().set_volume(volume)
}
/// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.set_audio_track(stream_index)
}
/// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.set_subtitle_track(stream_index)
}
/// Get current state
pub fn state(&self) -> PlayerState {
self.backend.lock().unwrap().state()
self.backend.lock_safe().state()
}
/// Get current position
pub fn position(&self) -> f64 {
self.backend.lock().unwrap().position()
self.backend.lock_safe().position()
}
/// Get duration
pub fn duration(&self) -> Option<f64> {
self.backend.lock().unwrap().duration()
self.backend.lock_safe().duration()
}
/// Get queue reference
@@ -463,27 +568,27 @@ impl PlayerController {
/// Toggle shuffle
pub fn toggle_shuffle(&self) {
self.queue.lock().unwrap().toggle_shuffle();
self.queue.lock_safe().toggle_shuffle();
}
/// Cycle repeat mode
pub fn cycle_repeat(&self) {
self.queue.lock().unwrap().cycle_repeat();
self.queue.lock_safe().cycle_repeat();
}
/// Check if shuffle is enabled
pub fn is_shuffle(&self) -> bool {
self.queue.lock().unwrap().is_shuffle()
self.queue.lock_safe().is_shuffle()
}
/// Get repeat mode
pub fn repeat_mode(&self) -> RepeatMode {
self.queue.lock().unwrap().repeat_mode()
self.queue.lock_safe().repeat_mode()
}
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock().unwrap().volume()
self.backend.lock_safe().volume()
}
/// Check if muted
@@ -493,35 +598,35 @@ impl PlayerController {
/// Set audio settings (crossfade, gapless, normalization)
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
self.backend.lock().unwrap().set_audio_settings(settings)
self.backend.lock_safe().set_audio_settings(settings)
}
/// Get current audio settings
pub fn audio_settings(&self) -> AudioSettings {
self.backend.lock().unwrap().audio_settings()
self.backend.lock_safe().audio_settings()
}
// ===== Sleep Timer Methods =====
/// Set the event emitter for notifications
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
let mut event_emitter = self.event_emitter.lock().unwrap();
let mut event_emitter = self.event_emitter.lock_safe();
*event_emitter = Some(emitter);
}
/// Get the event emitter
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
self.event_emitter.lock().unwrap().clone()
self.event_emitter.lock_safe().clone()
}
/// Get sleep timer state
pub fn sleep_timer_state(&self) -> SleepTimerState {
self.sleep_timer.lock().unwrap().clone()
self.sleep_timer.lock_safe().clone()
}
/// Set sleep timer mode (in-memory only, not persisted)
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
let mut timer = self.sleep_timer.lock().unwrap();
let mut timer = self.sleep_timer.lock_safe();
timer.mode = mode.clone();
if let SleepTimerMode::Time { end_time } = mode {
let now = chrono::Utc::now().timestamp_millis();
@@ -544,17 +649,39 @@ impl PlayerController {
fn start_timer_thread(&self) {
let sleep_timer = self.sleep_timer.clone();
let event_emitter = self.event_emitter.clone();
let backend = self.backend.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(1));
let mut timer = sleep_timer.lock().unwrap();
let mut timer = sleep_timer.lock_safe();
if timer.is_active() {
timer.update_remaining_seconds();
// Time-based timer expired: stop playback
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
debug!("[SleepTimer] Time-based timer expired, stopping playback");
timer.cancel();
// Emit cancelled state
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: SleepTimerMode::Off,
remaining_seconds: 0,
});
}
drop(timer);
// Stop the backend
if let Err(e) = backend.lock_safe().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e);
}
continue;
}
// Emit update event
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode.clone(),
remaining_seconds: timer.remaining_seconds,
@@ -568,9 +695,9 @@ impl PlayerController {
/// Emit sleep timer changed event to frontend
fn emit_sleep_timer_changed(&self) {
let timer = self.sleep_timer.lock().unwrap().clone();
let timer = self.sleep_timer.lock_safe().clone();
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode,
remaining_seconds: timer.remaining_seconds,
@@ -580,12 +707,12 @@ impl PlayerController {
/// Emit queue changed event to frontend
pub fn emit_queue_changed(&self) {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
queue.items().len(), queue.current_index());
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::QueueChanged {
items: queue.items().to_vec(),
current_index: queue.current_index(),
@@ -603,19 +730,19 @@ impl PlayerController {
/// Get autoplay settings
pub fn autoplay_settings(&self) -> AutoplaySettings {
self.autoplay_settings.lock().unwrap().clone()
self.autoplay_settings.lock_safe().clone()
}
/// Set autoplay settings (in-memory only, persistence handled by command layer)
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
let validated = settings.with_validated_countdown();
*self.autoplay_settings.lock().unwrap() = validated;
*self.autoplay_settings.lock_safe() = validated;
}
/// Cancel active autoplay countdown
pub fn cancel_autoplay_countdown(&self) {
if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
*cancel_flag.lock().unwrap() = true;
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
*cancel_flag.lock_safe() = true;
}
}
@@ -658,7 +785,7 @@ impl PlayerController {
}
let current_item = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.current().cloned()
};
@@ -668,14 +795,24 @@ impl PlayerController {
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock().unwrap();
let timer = self.sleep_timer.lock_safe();
timer.mode.clone()
};
match &timer_mode {
SleepTimerMode::Time { end_time } => {
// If time has expired, stop instead of playing next
let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
// Stop at end of track
self.sleep_timer.lock().unwrap().cancel();
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
@@ -685,7 +822,7 @@ impl PlayerController {
&& self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
@@ -699,14 +836,31 @@ impl PlayerController {
}
// For video episodes, fetch next episode and show popup
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
if let Some(next_ep) = self.fetch_next_episode_for_item(&current).await? {
let settings = self.autoplay_settings.lock().unwrap().clone();
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
} else {
debug!("[PlayerController] No repository available for audio-path episode lookup");
None
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().clone();
// 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);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode: next_ep.0, // Repository MediaItem
next_episode: next_ep.1,
countdown_seconds: settings.countdown_seconds,
auto_advance: settings.enabled,
auto_advance: settings.enabled && !limit_reached,
});
}
// No next episode found
@@ -715,7 +869,7 @@ impl PlayerController {
// For audio/movies, check if there's a next track in the queue
let has_next = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.has_next()
};
@@ -728,6 +882,78 @@ impl PlayerController {
}
}
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
/// queue has no knowledge of the video item. This method bypasses the
/// queue lookup and end_reason check, using the provided Jellyfin item ID
/// to look up the item and check for next episodes.
pub async fn on_video_playback_ended(
&self,
item_id: &str,
repo: Arc<dyn crate::repository::MediaRepository>,
) -> Result<AutoplayDecision, String> {
// 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] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock_safe();
timer.mode.clone()
};
match &timer_mode {
SleepTimerMode::Time { end_time } => {
let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at video end");
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
return Ok(AutoplayDecision::Stop);
}
}
_ => {}
}
// Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
let settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode: next_ep.0,
next_episode: next_ep.1,
countdown_seconds: settings.countdown_seconds,
auto_advance: settings.enabled && !limit_reached,
});
}
// No next episode found
debug!("[PlayerController] No next episode found for {}", item_id);
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query)
async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes
@@ -735,34 +961,63 @@ impl PlayerController {
item.media_type == MediaType::Video
}
/// Fetch next episode for a series (using Repository)
async fn fetch_next_episode_for_item(&self, current: &MediaItem) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
let repo = self.repository.lock().unwrap().clone();
let Some(repo) = repo else {
return Ok(None);
};
/// Fetch next episode for a series by looking up the season's episodes
/// sorted by index number and picking the one after the current episode.
///
/// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
/// (which relies on watch history that may not be updated yet due to
/// the async nature of playback progress reporting).
async fn fetch_next_episode_for_item(
&self,
item_id: &str,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
use crate::repository::types::GetItemsOptions;
let jellyfin_id = current.jellyfin_id()
.ok_or_else(|| "No Jellyfin ID for current item".to_string())?;
// First, get the current item details from repository
let current_repo_item = repo.get_item(jellyfin_id)
// Get the current item details from repository
let current_repo_item = repo.get_item(item_id)
.await
.map_err(|e| format!("Failed to get current item: {}", e))?;
let series_id = current_repo_item.series_id.clone()
.ok_or_else(|| "Current item is not an episode".to_string())?;
// Fetch next up episodes for this series
let next_episodes = repo.get_next_up_episodes(Some(&series_id), Some(1))
.await
.map_err(|e| format!("Failed to fetch next episodes: {}", e))?;
if let Some(next) = next_episodes.first() {
// Verify it's not the same episode
if next.id != current_repo_item.id {
return Ok(Some((current_repo_item, next.clone())));
// Need season_id to fetch sibling episodes
let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
return Ok(None);
}
};
// Fetch all episodes in the season sorted by episode number
let options = GetItemsOptions {
sort_by: Some("IndexNumber".to_string()),
sort_order: Some("Ascending".to_string()),
limit: Some(500),
include_item_types: Some(vec!["Episode".to_string()]),
..Default::default()
};
let result = repo.get_items(&season_id, Some(options))
.await
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
// Sort client-side by index_number to ensure correct ordering
// (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));
debug!("[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];
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone())));
} else {
debug!("[PlayerController] Current episode is the last in the season");
}
} else {
debug!("[PlayerController] Current episode not found in season episodes");
}
Ok(None)
@@ -772,7 +1027,7 @@ impl PlayerController {
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().unwrap() = Some(cancel_flag.clone());
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
let event_emitter = self.event_emitter.clone();
@@ -783,7 +1038,7 @@ impl PlayerController {
std::thread::sleep(Duration::from_secs(1));
// Check cancellation
if *cancel_flag.lock().unwrap() {
if *cancel_flag.lock_safe() {
log::info!("[PlayerController] Autoplay countdown cancelled");
return;
}
@@ -791,21 +1046,15 @@ impl PlayerController {
remaining -= 1;
// Emit countdown tick event
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::CountdownTick {
remaining_seconds: remaining,
});
}
}
// Countdown finished - emit final tick at 0
// Countdown finished (final tick at 0 was already emitted inside the loop)
log::info!("[PlayerController] Autoplay countdown finished");
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::CountdownTick {
remaining_seconds: 0,
});
}
// The frontend can listen for countdown reaching 0 and trigger playback
});
}
}
@@ -898,7 +1147,7 @@ mod tests {
// Verify initial state
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -910,7 +1159,7 @@ mod tests {
// Verify queue is intact and index advanced
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -929,7 +1178,7 @@ mod tests {
// Verify queue still intact and index advanced again
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -942,7 +1191,7 @@ mod tests {
// Verify we're at the last item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -964,7 +1213,7 @@ mod tests {
// Verify we're at the last item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
}
@@ -975,7 +1224,7 @@ mod tests {
// Verify queue is still intact
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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)
@@ -1004,7 +1253,7 @@ mod tests {
// Verify we wrapped to the first item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -1023,7 +1272,7 @@ mod tests {
// Verify starting position
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
}
@@ -1033,7 +1282,7 @@ mod tests {
// Verify queue is intact and index moved back
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
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");
@@ -1130,6 +1379,44 @@ mod tests {
}
}
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
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!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test]
fn test_seek_to_zero() {
let controller = PlayerController::default();
@@ -1202,7 +1489,7 @@ mod tests {
// Set sleep timer to end of track
{
let mut timer = controller.sleep_timer.lock().unwrap();
let mut timer = controller.sleep_timer.lock_safe();
timer.mode = SleepTimerMode::EndOfTrack;
}
@@ -1217,7 +1504,7 @@ mod tests {
// Verify timer was cancelled
{
let timer = controller.sleep_timer.lock().unwrap();
let timer = controller.sleep_timer.lock_safe();
assert!(
matches!(timer.mode, SleepTimerMode::Off),
"Sleep timer should be cancelled after EndOfTrack"
+9 -8
View File
@@ -1,3 +1,4 @@
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
@@ -190,7 +191,7 @@ impl MpvBackend {
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock().unwrap().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 {
@@ -202,7 +203,7 @@ 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().unwrap().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 {
@@ -308,7 +309,7 @@ impl MpvBackend {
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock().unwrap();
let state = state_for_position.lock_safe();
state.current_media.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
@@ -376,7 +377,7 @@ impl PlayerBackend for MpvBackend {
// Update state
{
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.current_media = Some(media.clone());
}
@@ -422,7 +423,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to stop: {:?}", e),
})?;
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.current_media = None;
Ok(())
@@ -460,7 +461,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to set volume: {:?}", e),
})?;
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.volume = clamped;
Ok(())
@@ -480,7 +481,7 @@ impl PlayerBackend for MpvBackend {
}
fn state(&self) -> PlayerState {
let state = self.state.lock().unwrap();
let state = self.state.lock_safe();
if let Some(ref media) = state.current_media {
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
@@ -506,7 +507,7 @@ impl PlayerBackend for MpvBackend {
}
fn volume(&self) -> f32 {
let state = self.state.lock().unwrap();
let state = self.state.lock_safe();
state.volume
}
+2 -2
View File
@@ -6,7 +6,7 @@ use super::media::{MediaItem, MediaSource, QueueContext};
/// Repeat mode for the queue
///
/// TRACES: UR-005 | DR-005
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RepeatMode {
#[default]
@@ -18,7 +18,7 @@ pub enum RepeatMode {
/// Queue manager for playlist functionality
///
/// TRACES: UR-005, UR-015 | DR-005, DR-020
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct QueueManager {
/// All items in the queue
items: Vec<MediaItem>,
+143
View File
@@ -0,0 +1,143 @@
//! Video seek strategy decision logic.
//!
//! This is pure logic, extracted from the command layer so it can be unit-tested
//! in the player core. The `player_seek_video` command translates the resulting
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
BackendReloadStream,
}
/// Determine the video seek strategy based on stream characteristics.
///
/// This is a pure function extracted for testability.
///
/// # Arguments
/// * `is_local` - Whether the file is a local download
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
is_hls: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
return VideoSeekStrategy::LocalNativeSeek;
}
// HLS streams and direct play (non-transcoded) support native seeking
if is_hls || !needs_transcoding {
if use_html5 {
// HTML5 backend - frontend handles seeking via videoElement.currentTime
// We don't call backend.seek() because video is in HTML5 element, not in MPV
VideoSeekStrategy::Html5NativeSeek
} else {
// Native backend (MPV) - backend handles seeking
VideoSeekStrategy::BackendNativeSeek
}
} else {
// Transcoded non-HLS streams need server-side seek (reload from new position)
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test video seek strategy for local files
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek
);
}
/// Test video seek strategy for HLS streams
#[test]
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// HLS with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
* beyond individual playback states. Enables persistent UI (miniplayer) and
* proper transitions between content types.
*
* See SoftwareArchitecture.md Section 2.1 for state machine diagram.
* See docs/architecture/01-rust-backend.md for the state machine diagram.
*/
use log::info;
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
use super::media::MediaItem;
/// Media session type tracking the high-level playback context
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MediaSessionType {
/// No active session - browsing library
+2 -2
View File
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
/// Sleep timer mode - determines when playback should stop
/// TRACES: UR-026 | DR-029
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum SleepTimerMode {
/// Timer is off
@@ -19,7 +19,7 @@ pub enum SleepTimerMode {
}
/// Sleep timer state
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SleepTimerState {
pub mode: SleepTimerMode,
+261 -3
View File
@@ -5,7 +5,7 @@ use super::media::MediaItem;
/// Tracks why playback ended to determine autoplay behavior
///
/// TRACES: UR-005 | DR-001
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EndReason {
/// Track played to completion (natural end) - trigger autoplay
@@ -23,7 +23,7 @@ pub enum EndReason {
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
///
/// TRACES: UR-005 | DR-001
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum PlayerState {
#[default]
@@ -61,7 +61,12 @@ pub enum PlayerState {
}
impl PlayerState {
/// Get the current playback position if available
/// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> {
match self {
PlayerState::Playing { position, .. } => Some(*position),
@@ -81,3 +86,256 @@ impl PlayerState {
matches!(self, PlayerState::Paused { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_end_reason_finished() {
let reason = EndReason::Finished;
let json = serde_json::to_string(&reason);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("finished"));
}
#[test]
fn test_end_reason_user_skip() {
let reason = EndReason::UserSkip;
let json = serde_json::to_string(&reason);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("userskip"));
}
#[test]
fn test_end_reason_user_stop() {
let reason = EndReason::UserStop;
let json = serde_json::to_string(&reason).unwrap();
assert!(json.contains("userstop"));
}
#[test]
fn test_end_reason_error() {
let reason = EndReason::Error;
let json = serde_json::to_string(&reason).unwrap();
assert!(json.contains("error"));
}
#[test]
fn test_end_reason_new_track_loaded() {
let reason = EndReason::NewTrackLoaded;
let json = serde_json::to_string(&reason).unwrap();
assert!(json.contains("newtrackloa"));
}
#[test]
fn test_end_reason_all_variants() {
let reasons = vec![
EndReason::Finished,
EndReason::UserSkip,
EndReason::UserStop,
EndReason::Error,
EndReason::NewTrackLoaded,
];
for reason in reasons {
let json = serde_json::to_string(&reason);
assert!(json.is_ok());
}
}
#[test]
fn test_end_reason_equality() {
let reason1 = EndReason::Finished;
let reason2 = EndReason::Finished;
assert_eq!(reason1, reason2);
let reason3 = EndReason::UserSkip;
assert_ne!(reason1, reason3);
}
#[test]
fn test_end_reason_clone() {
let reason = EndReason::Finished;
let cloned = reason.clone();
assert_eq!(reason, cloned);
}
#[test]
fn test_player_state_idle_default() {
let state = PlayerState::default();
assert!(matches!(state, PlayerState::Idle));
}
#[test]
fn test_player_state_idle_serialization() {
let state = PlayerState::Idle;
let json = serde_json::to_string(&state);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("idle"));
}
#[test]
fn test_player_state_position_when_idle() {
let state = PlayerState::Idle;
assert_eq!(state.position(), None);
}
#[test]
fn test_player_state_is_playing_when_idle() {
let state = PlayerState::Idle;
assert!(!state.is_playing());
}
#[test]
fn test_player_state_is_paused_when_idle() {
let state = PlayerState::Idle;
assert!(!state.is_paused());
}
#[test]
fn test_player_state_loading() {
let media = create_test_media_item("item-1", "Test Item");
let state = PlayerState::Loading { media: media.clone() };
assert!(matches!(state, PlayerState::Loading { .. }));
assert_eq!(state.position(), None);
assert!(!state.is_playing());
}
#[test]
fn test_player_state_playing_position() {
let media = create_test_media_item("item-2", "Playing Item");
let state = PlayerState::Playing {
media,
position: 45.5,
duration: 180.0,
};
assert!(state.is_playing());
assert!(!state.is_paused());
assert_eq!(state.position(), Some(45.5));
}
#[test]
fn test_player_state_paused_position() {
let media = create_test_media_item("item-3", "Paused Item");
let state = PlayerState::Paused {
media,
position: 123.75,
duration: 300.0,
};
assert!(state.is_paused());
assert!(!state.is_playing());
assert_eq!(state.position(), Some(123.75));
}
#[test]
fn test_player_state_seeking() {
let media = create_test_media_item("item-4", "Seeking Item");
let state = PlayerState::Seeking {
media,
target: 60.0,
};
assert!(!state.is_playing());
assert!(!state.is_paused());
assert_eq!(state.position(), None);
}
#[test]
fn test_player_state_error_with_media() {
let media = create_test_media_item("item-5", "Error Item");
let state = PlayerState::Error {
media: Some(media),
error: "Playback failed".to_string(),
};
assert!(!state.is_playing());
assert_eq!(state.position(), None);
}
#[test]
fn test_player_state_error_without_media() {
let state = PlayerState::Error {
media: None,
error: "Connection lost".to_string(),
};
assert!(!state.is_playing());
assert_eq!(state.position(), None);
}
#[test]
fn test_player_state_clone() {
let state = PlayerState::Idle;
let cloned = state.clone();
assert!(matches!(cloned, PlayerState::Idle));
}
#[test]
fn test_player_state_playing_serialization() {
let media = create_test_media_item("item-6", "Serial Item");
let state = PlayerState::Playing {
media,
position: 30.0,
duration: 120.0,
};
let json = serde_json::to_string(&state);
assert!(json.is_ok());
let serialized = json.unwrap();
assert!(serialized.contains("playing"));
assert!(serialized.contains("30"));
}
#[test]
fn test_player_state_multiple_positions() {
let positions = vec![0.0, 45.5, 100.0, 999.99];
for pos in positions {
let media = create_test_media_item("item-test", "Test");
let state = PlayerState::Playing {
media,
position: pos,
duration: 1000.0,
};
assert_eq!(state.position(), Some(pos));
}
}
// Helper function to create test MediaItem instances
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
MediaItem {
id: id.to_string(),
title: title.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: Some("Video".to_string()),
playlist_id: None,
duration: Some(100.0),
artwork_url: None,
media_type: super::super::media::MediaType::Video,
source: super::super::media::MediaSource::DirectUrl {
url: "http://example.com/media".to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
}
}
}
+421 -58
View File
@@ -5,6 +5,8 @@
// @req: DR-012 - Local database for media metadata cache
// @req: DR-013 - Repository pattern for online/offline data access
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use std::sync::Arc;
use async_trait::async_trait;
@@ -37,6 +39,12 @@ impl HybridRepository {
}
}
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Delegates to online repository for connection reuse and proper auth.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
self.online.download_bytes(url).await
}
/// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths.
pub async fn get_video_stream_url(
@@ -49,14 +57,87 @@ impl HybridRepository {
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
}
/// Race cache vs server, return first valid result
/// Prefer cache if it has meaningful content, otherwise use server
/// Search only the local SQLite cache (downloaded content).
///
/// Core algorithm of the cache-first parallel racing strategy.
/// Runs both cache and server queries concurrently, then:
/// 1. If cache has meaningful content → return cache (fast path)
/// 2. If cache is empty/stale → return server (fresh data)
/// 3. If server fails → return cache even if empty (offline fallback)
/// Fast (100ms timeout) — used to render instant results before the server
/// responds. Returns an empty result rather than erroring on timeout so the
/// caller can still fall through to the server.
pub async fn search_cache_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let query = query.to_string();
self.cache_with_timeout(async move { offline.search(&query, options).await })
.await
}
/// Search only the live Jellyfin server (full library).
pub async fn search_server_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
self.online.search(query, options).await
}
/// Merge cache and server search results into a single de-duplicated list.
///
/// Ordering: local (cached/downloaded) items first, then server-only items
/// appended. On a duplicate `id`, the server's item wins (fresher, more
/// complete metadata) but keeps the local item's earlier position.
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
use std::collections::HashMap;
// Index server items by id so we can (a) override duplicates with the
// server's metadata and (b) know which server items are brand new.
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
for item in server.items {
if !server_by_id.contains_key(&item.id) {
server_order.push(item.id.clone());
}
server_by_id.insert(item.id.clone(), item);
}
let mut items: Vec<MediaItem> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
// Local items first, in their original order. If the server also
// returned this item, take the server's copy (newer metadata).
for local in cache.items {
if !seen.insert(local.id.clone()) {
continue;
}
match server_by_id.remove(&local.id) {
Some(server_item) => items.push(server_item),
None => items.push(local),
}
}
// Then append server-only items, preserving the server's order.
for id in server_order {
if let Some(server_item) = server_by_id.remove(&id) {
if seen.insert(id) {
items.push(server_item);
}
}
}
let total_record_count = items.len();
SearchResult {
items,
total_record_count,
}
}
/// Cache-first query: try cache, fall back to server on miss.
///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
/// 2. If cache has meaningful content → return immediately (fast path)
/// 3. If cache is empty/stale → query server (fresh data)
/// 4. If server fails → return cache even if empty (offline fallback)
///
/// @req: UR-002 - Access media when online or offline
/// @req: DR-013 - Repository pattern for online/offline data access
@@ -70,24 +151,20 @@ impl HybridRepository {
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
{
// Wait for both to complete (cache has 100ms timeout)
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
// Try cache first (100ms timeout already applied by callers)
let cache_result = cache_future.await;
// Prefer cache if it has meaningful content
if let Ok(data) = &cache_result {
if data.has_content() {
debug!("[HybridRepo] Using cache result (has content)");
debug!("[HybridRepo] Cache hit, returning immediately");
return Ok(data.clone());
}
}
// Fall back to server result
match server_result {
Ok(data) => {
debug!("[HybridRepo] Using server result");
// TODO: Spawn background cache update
Ok(data)
}
// Cache miss — fall back to server
debug!("[HybridRepo] Cache miss, querying server");
match server_future.await {
Ok(data) => Ok(data),
Err(e) => {
// Server failed, try to return cache even if empty
cache_result.or(Err(e))
@@ -129,46 +206,57 @@ impl MediaRepository for HybridRepository {
let parent_id_for_save = parent_id.clone();
let opts_clone = options.clone();
// Check cache first to see if we have data
let cache_future = self.cache_with_timeout(async move {
offline.get_items(&parent_id, opts_clone).await
// Start server request in background (non-blocking)
let server_handle = tokio::spawn(async move {
online.get_items(&parent_id_clone, options).await
});
let server_future = async move {
online.get_items(&parent_id_clone, options).await
};
// Check cache first (fast, 100ms timeout)
let cache_result = self.cache_with_timeout(async move {
offline.get_items(&parent_id, opts_clone).await
}).await;
// Wait for both, prefer cache if available
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
// Check if cache had meaningful content
let cache_had_content = cache_result.as_ref()
.map(|data| data.has_content())
.unwrap_or(false);
// Prefer cache if it has content
let result = if cache_had_content {
debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
cache_result?
} else {
// Use server result and save to cache for next time
let server_data = server_result?;
if !server_data.items.is_empty() {
let items_clone = server_data.items.clone();
// Cache hit: return immediately, update cache in background
if let Ok(data) = &cache_result {
if data.has_content() {
debug!("[HybridRepo] Cache hit for get_items, returning immediately for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
// Background: save server result to cache when it arrives
tokio::spawn(async move {
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
} else {
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
match server_handle.await {
Ok(Ok(server_data)) if !server_data.items.is_empty() => {
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &server_data.items).await {
warn!("[HybridRepo] Background cache update failed: {:?}", e);
} else {
debug!("[HybridRepo] Background updated {} cached items for parent {}", server_data.items.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
}
}
_ => {} // Server failed or returned empty — keep existing cache
}
});
return Ok(data.clone());
}
}
server_data
};
Ok(result)
// Cache miss — wait for server result
match server_handle.await {
Ok(Ok(server_data)) => {
if !server_data.items.is_empty() {
let items_clone = server_data.items.clone();
tokio::spawn(async move {
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
} else {
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
}
});
}
Ok(server_data)
}
Ok(Err(e)) => cache_result.or(Err(e)),
Err(join_err) => cache_result.or(Err(RepoError::Network {
message: format!("Server task failed: {}", join_err),
})),
}
}
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
@@ -261,6 +349,27 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let parent_id_owned = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_owned.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
});
let server_future = async move {
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
};
self.parallel_race(cache_future, server_future).await
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -408,6 +517,109 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
// Write operation - delegate directly to server
self.online.create_playlist(name, item_ids).await
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.delete_playlist(playlist_id).await
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.rename_playlist(playlist_id, name).await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let offline = Arc::clone(&self.offline);
let offline_for_save = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let playlist_id = playlist_id.to_string();
let playlist_id_clone = playlist_id.clone();
let playlist_id_for_save = playlist_id.clone();
// Start server request in background (non-blocking)
let server_handle = tokio::spawn(async move {
online.get_playlist_items(&playlist_id_clone).await
});
// Check cache first (fast, 100ms timeout)
let cache_result = self.cache_with_timeout(async move {
offline.get_playlist_items(&playlist_id).await
}).await;
// Cache hit: return immediately, update cache in background
if let Ok(data) = &cache_result {
if data.has_content() {
debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
tokio::spawn(async move {
if let Ok(Ok(server_entries)) = server_handle.await {
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
}
}
});
return cache_result;
}
}
// Cache miss — wait for server result
match server_handle.await {
Ok(Ok(entries)) => {
let entries_clone = entries.clone();
tokio::spawn(async move {
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
warn!("[HybridRepo] Failed to save playlist items to cache: {:?}", e);
}
});
Ok(entries)
}
Ok(Err(e)) => cache_result.or(Err(e)),
Err(join_err) => cache_result.or(Err(RepoError::Network {
message: format!("Server task failed: {}", join_err),
})),
}
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.add_to_playlist(playlist_id, item_ids).await
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.remove_from_playlist(playlist_id, entry_ids).await
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
// Write operation - delegate directly to server
self.online.move_playlist_item(playlist_id, item_id, new_index).await
}
}
#[cfg(test)]
@@ -432,16 +644,16 @@ mod tests {
}
fn get_query_count(&self) -> usize {
*self.query_count.lock().unwrap()
*self.query_count.lock_safe()
}
fn get_save_count(&self) -> usize {
*self.save_count.lock().unwrap()
*self.save_count.lock_safe()
}
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
*self.save_count.lock().unwrap() += 1;
*self.items.lock().unwrap() = items.to_vec();
*self.save_count.lock_safe() += 1;
*self.items.lock_safe() = items.to_vec();
Ok(items.len())
}
}
@@ -453,8 +665,8 @@ mod tests {
}
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
*self.query_count.lock().unwrap() += 1;
let items = self.items.lock().unwrap().clone();
*self.query_count.lock_safe() += 1;
let items = self.items.lock_safe().clone();
let count = items.len();
Ok(SearchResult {
items,
@@ -486,6 +698,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -552,6 +772,38 @@ mod tests {
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
unimplemented!()
}
}
/// Mock online repository that returns predefined items
@@ -569,7 +821,7 @@ mod tests {
}
fn get_query_count(&self) -> usize {
*self.query_count.lock().unwrap()
*self.query_count.lock_safe()
}
}
@@ -580,7 +832,7 @@ mod tests {
}
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
*self.query_count.lock().unwrap() += 1;
*self.query_count.lock_safe() += 1;
Ok(SearchResult {
items: self.items.clone(),
total_record_count: self.items.len(),
@@ -611,6 +863,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -677,6 +937,38 @@ mod tests {
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
unimplemented!()
}
}
fn create_test_item(id: &str, name: &str) -> MediaItem {
@@ -878,4 +1170,75 @@ mod tests {
};
assert!(result_with_items.has_content(), "Result with items should have content");
}
#[test]
fn test_merge_search_local_first_then_server_appended() {
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("c", "Server C"),
create_test_item("d", "Server D"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
// Local items first (in order), then server-only items appended.
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
assert_eq!(merged.total_record_count, 4);
}
#[test]
fn test_merge_search_dedupes_with_server_winning() {
// "b" appears in both. Server metadata should win, but the item keeps
// its earlier (local) position and is not duplicated.
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("b", "Server B (fresher)"),
create_test_item("c", "Server C"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
assert_eq!(merged.total_record_count, 3);
}
#[test]
fn test_merge_search_handles_empty_sides() {
let only_server = HybridRepository::merge_search_results(
SearchResult { items: vec![], total_record_count: 0 },
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
);
assert_eq!(only_server.items.len(), 1);
assert_eq!(only_server.items[0].id, "x");
let only_cache = HybridRepository::merge_search_results(
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
SearchResult { items: vec![], total_record_count: 0 },
);
assert_eq!(only_cache.items.len(), 1);
assert_eq!(only_cache.items[0].id, "y");
}
}
+77
View File
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get albums the user has played, but not recently ("rediscover" / haven't
/// listened to in a while). Returns albums sorted by least-recently played
/// first, optionally restricted to a parent library.
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
@@ -147,6 +156,8 @@ pub trait MediaRepository: Send + Sync {
) -> String;
/// Get subtitle URL (synchronous - just constructs URL)
/// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
#[allow(dead_code)]
fn get_subtitle_url(
&self,
item_id: &str,
@@ -156,6 +167,8 @@ pub trait MediaRepository: Send + Sync {
) -> String;
/// Get video download URL (synchronous - just constructs URL)
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
#[allow(dead_code)]
fn get_video_download_url(
&self,
item_id: &str,
@@ -187,4 +200,68 @@ pub trait MediaRepository: Send + Sync {
item_id: &str,
limit: Option<usize>,
) -> Result<SearchResult, RepoError>;
// ===== Playlist Methods =====
/// Create a new playlist on the server
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError>;
/// Delete a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
/// Rename a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
/// Get playlist items with PlaylistItemId (needed for remove/reorder)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError>;
/// Add items to a playlist
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError>;
/// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError>;
/// Move a playlist item to a new position
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-020 - Add/remove items from playlist
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError>;
}
+622 -16
View File
@@ -162,6 +162,26 @@ impl OfflineRepository {
let now = chrono::Utc::now().to_rfc3339();
// Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
// when replacing stub parent items with their actual data
self.db_service.execute(Query::new("PRAGMA foreign_keys = OFF")).await
.map_err(|e| RepoError::Database { message: e })?;
// Ensure we re-enable foreign keys even if an error occurs
let result = self.save_to_cache_impl(parent_id, items, &now).await;
// Re-enable foreign key constraints
let _ = self.db_service.execute(Query::new("PRAGMA foreign_keys = ON")).await;
result
}
async fn save_to_cache_impl(
&self,
parent_id: &str,
items: &[MediaItem],
now: &str,
) -> Result<usize, RepoError> {
// Collect all unique parent IDs referenced by items being saved
let mut parent_ids = std::collections::HashSet::new();
parent_ids.insert(parent_id.to_string());
@@ -185,7 +205,7 @@ impl OfflineRepository {
QueryParam::String(self.server_id.clone()),
QueryParam::String("Parent".to_string()),
QueryParam::String("Folder".to_string()),
QueryParam::String(now.clone()),
QueryParam::String(now.to_string()),
],
);
@@ -206,8 +226,6 @@ impl OfflineRepository {
let backdrop_tags_json = item.backdrop_image_tags.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
let now = chrono::Utc::now().to_rfc3339();
// Use INSERT OR REPLACE to upsert items
let query = Query::with_params(
"INSERT OR REPLACE INTO items (
@@ -315,7 +333,7 @@ impl OfflineRepository {
Some(r) => QueryParam::String(r.clone()),
None => QueryParam::Null,
},
QueryParam::String(now.clone()),
QueryParam::String(now.to_string()),
],
);
@@ -328,6 +346,56 @@ impl OfflineRepository {
Ok(count)
}
/// Cache playlist items from server into local database
/// Called by HybridRepository after fetching from online
pub async fn save_playlist_items_to_cache(
&self,
playlist_id: &str,
entries: &[PlaylistEntry],
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let user_id = self.user_id.clone();
let entries: Vec<(String, String, usize)> = entries
.iter()
.enumerate()
.map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
.collect();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Ensure playlist record exists
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
))?;
// Clear existing entries and re-insert
tx.execute(Query::with_params(
"DELETE FROM playlist_items WHERE playlist_id = ?",
vec![QueryParam::String(playlist_id.clone())],
))?;
for (_, item_id, sort_order) in &entries {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![
QueryParam::String(playlist_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int(*sort_order as i32),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to cache playlist items: {}", e),
})
}
}
#[async_trait]
@@ -562,7 +630,7 @@ impl MediaRepository for OfflineRepository {
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Resume items are always playable items (Audio, Movie, Episode), so simple JOIN with downloads
// Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
let (sql, params) = if let Some(pid) = parent_id {
(
format!(
@@ -578,6 +646,7 @@ impl MediaRepository for OfflineRepository {
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
AND d.status = 'completed'
AND i.item_type IN ('Movie', 'Episode')
ORDER BY ud.last_played_at DESC
LIMIT {}", limit_val
),
@@ -602,6 +671,7 @@ impl MediaRepository for OfflineRepository {
WHERE i.server_id = ? AND ud.user_id = ?
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
AND d.status = 'completed'
AND i.item_type IN ('Movie', 'Episode')
ORDER BY ud.last_played_at DESC
LIMIT {}", limit_val
),
@@ -708,6 +778,17 @@ impl MediaRepository for OfflineRepository {
Ok(items)
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// "Rediscover" is a discovery feature over the full server library.
// Offline only holds downloaded items, so there is nothing meaningful
// to surface here; the hybrid repo serves this from the server instead.
Ok(Vec::new())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
@@ -749,41 +830,57 @@ impl MediaRepository for OfflineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items
let mut genre_set = std::collections::HashSet::new();
// Derive genres from cached albums, tallying how many albums carry each
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL",
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()),
]
],
)
} else {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())]
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0))
let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.await
.map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
genre_set.insert(genre);
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = genre_set
let genres = counts
.into_iter()
.map(|name| Genre { id: name.clone(), name })
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres)
@@ -1070,6 +1167,254 @@ impl MediaRepository for OfflineRepository {
// Similar items require server-side computation and are not available offline
Err(RepoError::Offline)
}
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
let playlist_id = uuid::Uuid::new_v4().to_string();
let user_id = self.user_id.clone();
let name = name.to_string();
let item_ids = item_ids.to_vec();
let pid = playlist_id.clone();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
tx.execute(Query::with_params(
"INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
))?;
for (i, item_id) in item_ids.iter().enumerate() {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to create playlist: {}", e),
})?;
Ok(PlaylistCreatedResult { id: playlist_id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
let query = Query::with_params(
"DELETE FROM playlists WHERE id = ?",
vec![QueryParam::String(playlist_id.to_string())],
);
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
message: format!("Failed to delete playlist: {}", e),
})?;
Ok(())
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
let query = Query::with_params(
"UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![
QueryParam::String(name.to_string()),
QueryParam::String(playlist_id.to_string()),
],
);
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
message: format!("Failed to rename playlist: {}", e),
})?;
Ok(())
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let query = Query::with_params(
"SELECT pi.id, \
i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
i.parent_index_number \
FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \
ORDER BY pi.sort_order ASC",
vec![QueryParam::String(playlist_id.to_string())],
);
let items = self.db_service
.query_many(query, |row| {
let entry_id: i64 = row.get(0)?;
// Columns offset by 1 because first column is pi.id
let cached = CachedItem {
id: row.get(1)?,
name: row.get(2)?,
item_type: row.get(3)?,
server_id: row.get(4)?,
parent_id: row.get(5)?,
library_id: row.get(6)?,
overview: row.get(7)?,
genres: row.get(8)?,
runtime_ticks: row.get(9)?,
production_year: row.get(10)?,
community_rating: row.get(11)?,
official_rating: row.get(12)?,
primary_image_tag: row.get(13)?,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: row.get(14)?,
album_name: row.get(15)?,
album_artist: row.get(16)?,
artists: row.get(17)?,
index_number: row.get(18)?,
series_id: row.get(19)?,
series_name: row.get(20)?,
season_id: row.get(21)?,
season_name: row.get(22)?,
parent_index_number: row.get(23)?,
};
Ok((entry_id.to_string(), cached))
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to get playlist items: {}", e),
})?;
Ok(items
.into_iter()
.map(|(entry_id, cached)| PlaylistEntry {
playlist_item_id: entry_id,
item: Self::cached_item_to_media_item(cached, None),
})
.collect())
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
// Get current max sort_order
let max_query = Query::with_params(
"SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
vec![QueryParam::String(playlist_id.to_string())],
);
let max_order: i32 = self.db_service
.query_one(max_query, |row| row.get(0))
.await
.unwrap_or(-1);
let playlist_id = playlist_id.to_string();
let item_ids = item_ids.to_vec();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
for (i, item_id) in item_ids.iter().enumerate() {
tx.execute(Query::with_params(
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
vec![
QueryParam::String(playlist_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int(max_order + 1 + i as i32),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to add items to playlist: {}", e),
})?;
Ok(())
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let entry_ids = entry_ids.to_vec();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
for entry_id in &entry_ids {
tx.execute(Query::with_params(
"DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to remove items from playlist: {}", e),
})?;
Ok(())
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
let playlist_id = playlist_id.to_string();
let item_id = item_id.to_string();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Get all items ordered by sort_order
let items: Vec<(i64, String)> = tx.query_many(
Query::with_params(
"SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
vec![QueryParam::String(playlist_id)],
),
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
// Find the item to move
let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
if let Some(old_pos) = old_idx {
let mut ids = items;
let entry = ids.remove(old_pos);
let insert_at = (new_index as usize).min(ids.len());
ids.insert(insert_at, entry);
// Renumber all sort_orders
for (i, (entry_id, _)) in ids.iter().enumerate() {
tx.execute(Query::with_params(
"UPDATE playlist_items SET sort_order = ? WHERE id = ?",
vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
))?;
}
}
Ok(())
})
.await
.map_err(|e| RepoError::Database {
message: format!("Failed to move playlist item: {}", e),
})?;
Ok(())
}
}
#[cfg(test)]
@@ -1135,6 +1480,27 @@ mod tests {
playback_context_id TEXT,
PRIMARY KEY (user_id, item_id)
);
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_local INTEGER DEFAULT 0,
jellyfin_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
CREATE TABLE playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(playlist_id, item_id)
);
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
"#).unwrap();
// Insert a test server
@@ -1312,4 +1678,244 @@ mod tests {
assert!(result.is_ok(), "Simple case should work: {:?}", result);
assert_eq!(result.unwrap(), 3);
}
// ===== Playlist Tests =====
/// Helper to seed items into the DB for playlist tests
async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
let items: Vec<MediaItem> = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect();
repo.save_to_cache("library-1", &items).await.unwrap();
}
#[tokio::test]
async fn test_playlist_create_empty() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let result = repo.create_playlist("My Playlist", &[]).await;
assert!(result.is_ok());
let created = result.unwrap();
assert!(!created.id.is_empty(), "Should return a non-empty playlist ID");
// Verify playlist exists in DB
let name: String = db_service
.query_one(
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(name, "My Playlist");
}
#[tokio::test]
async fn test_playlist_create_with_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0].item.id, "t1");
assert_eq!(items[1].item.id, "t2");
assert_eq!(items[2].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_delete() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1"]).await;
let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap();
// Delete it
repo.delete_playlist(&created.id).await.unwrap();
// Verify playlist is gone
let count: i32 = db_service
.query_one(
Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(count, 0);
// Verify cascade deleted playlist_items
let item_count: i32 = db_service
.query_one(
Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(item_count, 0);
}
#[tokio::test]
async fn test_playlist_rename() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let created = repo.create_playlist("Original Name", &[]).await.unwrap();
repo.rename_playlist(&created.id, "New Name").await.unwrap();
let name: String = db_service
.query_one(
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(name, "New Name");
}
#[tokio::test]
async fn test_playlist_get_items_preserves_order() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c"]).await;
let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
// Order should match insertion order: c, a, b
assert_eq!(items[0].item.id, "c");
assert_eq!(items[1].item.id, "a");
assert_eq!(items[2].item.id, "b");
// Each entry should have a unique playlist_item_id
assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
}
#[tokio::test]
async fn test_playlist_get_items_empty_playlist() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
let created = repo.create_playlist("Empty", &[]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert!(items.is_empty());
}
#[tokio::test]
async fn test_playlist_add_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap();
// Add two more tracks
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
assert_eq!(items[0].item.id, "t1");
assert_eq!(items[1].item.id, "t2");
assert_eq!(items[2].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_add_duplicate_items_ignored() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1"]).await;
let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
// Try to add the same item again
repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)");
}
#[tokio::test]
async fn test_playlist_remove_items() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["t1", "t2", "t3"]).await;
let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items.len(), 3);
// Remove the middle track by its entry ID
let entry_id_to_remove = items[1].playlist_item_id.clone();
repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap();
let items_after = repo.get_playlist_items(&created.id).await.unwrap();
assert_eq!(items_after.len(), 2);
assert_eq!(items_after[0].item.id, "t1");
assert_eq!(items_after[1].item.id, "t3");
}
#[tokio::test]
async fn test_playlist_move_item_forward() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c", "d"]).await;
let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
// Move 'a' (index 0) to index 2: expect b, c, a, d
repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a", "d"]);
}
#[tokio::test]
async fn test_playlist_move_item_backward() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c", "d"]).await;
let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
// Move 'd' (index 3) to index 0: expect d, a, b, c
repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["d", "a", "b", "c"]);
}
#[tokio::test]
async fn test_playlist_move_item_to_end() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b", "c"]).await;
let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap();
// Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["b", "c", "a"]);
}
#[tokio::test]
async fn test_playlist_move_nonexistent_item_is_noop() {
let db_service = create_test_db();
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
seed_items(&repo, &["a", "b"]).await;
let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap();
// Move a nonexistent item - should not error, just no-op
repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap();
let items = repo.get_playlist_items(&created.id).await.unwrap();
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b"]);
}
}
+539 -42
View File
@@ -1,3 +1,5 @@
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
use std::sync::Arc;
use async_trait::async_trait;
use log::{debug, error, info};
@@ -5,6 +7,7 @@ use log::{debug, error, info};
use log::warn;
use serde::{Deserialize, Serialize};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*};
@@ -14,6 +17,10 @@ pub struct OnlineRepository {
server_url: String,
user_id: String,
access_token: String,
/// Reports the outcome of every server request to the connectivity monitor.
/// This is the source of truth for the offline/online banner. `None` in
/// tests / contexts where connectivity tracking isn't wired up.
connectivity: Option<ConnectivityReporter>,
}
impl OnlineRepository {
@@ -28,6 +35,44 @@ impl OnlineRepository {
server_url,
user_id,
access_token,
connectivity: None,
}
}
/// Attach a connectivity reporter so server outcomes drive the reachability
/// state observed by the UI. See `report_outcome`.
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
self.connectivity = Some(reporter);
self
}
/// Feed a request outcome into the connectivity monitor.
///
/// Classification (matches docs/architecture/07-connectivity.md):
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
/// so it is reachable → `report_success` (instant recovery).
/// - `Network` → connection-level failure → `report_network_failure`
/// (subject to the time-window debounce before going offline).
/// - `Database` → not a server signal → ignored.
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
let Some(reporter) = &self.connectivity else {
return;
};
match result {
Ok(_)
| Err(RepoError::Authentication { .. })
| Err(RepoError::NotFound { .. })
| Err(RepoError::Server { .. }) => {
reporter.report_success().await;
}
Err(RepoError::Network { message }) => {
reporter.report_network_failure(Some(message.clone())).await;
}
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
// Local-side errors (cache failure / already-offline) — not a
// statement about the server's reachability, so ignore them.
}
}
}
@@ -36,8 +81,37 @@ impl OnlineRepository {
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
}
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Used by thumbnail cache to download images with proper auth and connection reuse.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
let request = self.http_client.client.get(url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| format!("Download failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
}
response.bytes().await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read bytes: {}", e))
}
/// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await;
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url)
@@ -85,6 +159,12 @@ impl OnlineRepository {
/// Make authenticated POST request
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let result = self.post_json_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url)
@@ -120,6 +200,16 @@ impl OnlineRepository {
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
let result = self.post_json_response_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
@@ -168,9 +258,16 @@ impl OnlineRepository {
})
}
/// Get a video stream URL for playback with optional seeking support.
/// For direct streams, uses /Videos/{id}/stream.mp4 with transcoding to support seeking.
/// For transcoded streams (HEVC/10-bit), includes StartTimeTicks parameter.
/// Get a video stream URL for playback at an arbitrary position (resume,
/// transcoded seeking, audio-track switching).
///
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
pub async fn get_video_stream_url(
&self,
item_id: &str,
@@ -185,12 +282,11 @@ impl OnlineRepository {
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build URL with progressive MP4 transcoding for seeking support
// This works for both direct streams and HEVC content
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("Container", "mp4".to_string()),
("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index),
@@ -198,6 +294,9 @@ impl OnlineRepository {
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
];
if let Some(source_id) = media_source_id {
@@ -216,7 +315,7 @@ impl OnlineRepository {
.join("&");
let url = format!(
"{}/Videos/{}/stream.mp4?{}",
"{}/Videos/{}/master.m3u8?{}",
self.server_url, item_id, query
);
@@ -232,6 +331,31 @@ struct ItemsResponse {
total_record_count: usize,
}
/// Jellyfin playlist creation response
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreatePlaylistResponse {
id: String,
}
/// Jellyfin playlist items response — items include PlaylistItemId
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)]
struct PlaylistItemsResponse {
items: Vec<JellyfinPlaylistItem>,
total_record_count: usize,
}
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinPlaylistItem {
playlist_item_id: String,
#[serde(flatten)]
item: JellyfinItem,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinItem {
@@ -444,10 +568,22 @@ impl MediaRepository for OnlineRepository {
if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive));
}
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
}
// Always request backdrop image fields
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -477,7 +613,7 @@ impl MediaRepository for OnlineRepository {
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, parent_id, limit_str
);
@@ -495,7 +631,7 @@ impl MediaRepository for OnlineRepository {
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
@@ -517,7 +653,7 @@ impl MediaRepository for OnlineRepository {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People", self.user_id, limit_str);
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
@@ -539,7 +675,7 @@ impl MediaRepository for OnlineRepository {
// Fetch more items to account for grouping reducing the count
let fetch_limit = limit_val * 3;
let endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, fetch_limit
);
@@ -635,10 +771,36 @@ impl MediaRepository for OnlineRepository {
Ok(final_result)
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
@@ -651,7 +813,12 @@ impl MediaRepository for OnlineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id);
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
@@ -668,17 +835,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre {
id: String,
name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
}
let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response
let genres: Vec<Genre> = response
.items
.into_iter()
.map(|g| Genre {
id: g.id,
name: g.name,
album_count: g.album_count.or(g.child_count),
})
.collect())
.collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
}
async fn search(
@@ -687,19 +878,30 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id, query, limit
self.user_id,
urlencoding::encode(query),
limit
);
if let Some(opts) = options {
if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
}
}
// Always request backdrop image fields
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -809,7 +1011,18 @@ impl MediaRepository for OnlineRepository {
("h264,hevc".to_string(), "aac,mp3".to_string())
});
#[cfg(not(target_os = "android"))]
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
@@ -1005,8 +1218,12 @@ impl MediaRepository for OnlineRepository {
image_type.as_str()
);
// Authentication is handled by X-Emby-Authorization header in download_bytes()
// Do NOT include api_key here — some Jellyfin servers reject requests when
// api_key is present but the token doesn't match the expected format.
let mut params: Vec<String> = Vec::new();
if let Some(opts) = options {
let mut params = Vec::new();
if let Some(width) = opts.max_width {
params.push(format!("maxWidth={}", width));
}
@@ -1019,11 +1236,11 @@ impl MediaRepository for OnlineRepository {
if let Some(tag) = opts.tag {
params.push(format!("tag={}", tag));
}
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
if !params.is_empty() {
url.push('?');
url.push_str(&params.join("&"));
}
url
@@ -1082,23 +1299,29 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let result = async {
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
.await;
Ok(())
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
@@ -1115,7 +1338,7 @@ impl MediaRepository for OnlineRepository {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
let mut endpoint = format!(
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, person_id, limit
);
@@ -1149,7 +1372,7 @@ impl MediaRepository for OnlineRepository {
// Try the /Similar endpoint which works for most items
let endpoint = format!(
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People",
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
item_id, self.user_id, limit_str
);
@@ -1163,6 +1386,146 @@ impl MediaRepository for OnlineRepository {
total_record_count: response.total_record_count,
})
}
// ===== Playlist Methods =====
async fn create_playlist(
&self,
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
let body = serde_json::json!({
"Name": name,
"Ids": item_ids,
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse =
self.post_json_response("/Playlists", &body).await?;
Ok(PlaylistCreatedResult { id: response.id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
let endpoint = format!("/Items/{}", playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
let endpoint = format!("/Items/{}", playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
);
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
debug!(
"[OnlineRepo] Got {} playlist items for {}",
response.items.len(),
playlist_id
);
Ok(response
.items
.into_iter()
.map(|pi| PlaylistEntry {
playlist_item_id: pi.playlist_item_id,
item: pi.item.to_media_item(self.user_id.clone()),
})
.collect())
}
async fn add_to_playlist(
&self,
playlist_id: &str,
item_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Adding {} items to playlist {}",
item_ids.len(),
playlist_id
);
let ids_param = item_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
self.post_json(&endpoint, &serde_json::json!({})).await
}
async fn remove_from_playlist(
&self,
playlist_id: &str,
entry_ids: &[String],
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Removing {} entries from playlist {}",
entry_ids.len(),
playlist_id
);
let ids_param = entry_ids.join(",");
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
async fn move_playlist_item(
&self,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> Result<(), RepoError> {
info!(
"[OnlineRepo] Moving item {} in playlist {} to index {}",
item_id, playlist_id, new_index
);
let endpoint = format!(
"/Playlists/{}/Items/{}/Move/{}",
playlist_id, item_id, new_index
);
self.post_json(&endpoint, &serde_json::json!({})).await
}
}
#[cfg(test)]
@@ -1183,6 +1546,91 @@ mod tests {
)
}
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
fn create_test_repository_with_connectivity(
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
.expect("Failed to create HTTP client for monitor");
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
let reporter = monitor.reporter();
let repo = create_test_repository().with_connectivity(reporter.clone());
(repo, reporter)
}
/// `report_outcome` is the seam between repository traffic and the
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
///
/// @req-test: UR-002 - Access media when online or offline
/// @req-test: DR-013 - Repository pattern for online/offline data access
#[tokio::test]
async fn test_report_outcome_classifies_server_answered_as_reachable() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a server that answers should be reported reachable"
);
}
// Ok should also report reachable.
reporter.mark_unreachable_for_test().await;
let ok: Result<(), RepoError> = Ok(());
repo.report_outcome(&ok).await;
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
}
/// Local-side errors must NOT flip reachability — they say nothing about the
/// server.
#[tokio::test]
async fn test_report_outcome_ignores_local_errors() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
!reporter.is_reachable().await,
"local-side error must not change reachability"
);
}
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
async fn test_report_outcome_network_error_is_debounced() {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a single network failure stays online (debounced)"
);
}
#[tokio::test]
async fn test_get_audio_stream_url_formats_correctly() {
let repo = create_test_repository();
@@ -1198,6 +1646,46 @@ mod tests {
);
}
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
"expected HLS master playlist, got: {url}"
);
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository();
@@ -1358,4 +1846,13 @@ mod tests {
assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2");
}
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
}
@@ -30,7 +30,8 @@ mod tests {
self.server_url, item_id, image_type
);
let mut params = vec![("api_key", self.access_token.clone())];
// No api_key — image downloads use X-Emby-Authorization header
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(opts) = options {
if let Some(max_width) = opts.max_width {
@@ -304,14 +305,11 @@ mod tests {
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
let download_url = repo.get_video_download_url("item123", "720p");
// These URLs are constructed in BACKEND and returned to frontend
// Frontend never receives this token directly
assert!(image_url.contains("api_key=super_secret_token"));
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
assert!(!image_url.contains("api_key="));
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
assert!(subtitle_url.contains("api_key=super_secret_token"));
assert!(download_url.contains("api_key=super_secret_token"));
// In actual implementation, frontend would only get the URL string
// Frontend cannot construct its own URLs or extract the token
}
#[test]
@@ -335,10 +333,10 @@ mod tests {
let url = repo.get_image_url("id123", "Primary", None);
// Should be valid format
// Should be valid format (no api_key — auth via header)
assert!(url.starts_with("https://server.com"));
assert!(url.contains("/Items/id123/Images/Primary"));
assert!(url.contains("?api_key="));
assert!(!url.contains("api_key="));
}
#[test]
@@ -353,13 +351,13 @@ mod tests {
let url = repo.get_image_url("id123", "Primary", Some(&options));
// Should have single ? separator
// Should have single ? separator with params
let question_marks = url.matches('?').count();
assert_eq!(question_marks, 1);
// Should have ampersands between params
assert!(url.contains("?"));
assert!(url.contains("&"));
// Should have params for maxWidth and maxHeight
assert!(url.contains("maxWidth=300"));
assert!(url.contains("maxHeight=200"));
}
#[test]
@@ -368,8 +366,7 @@ mod tests {
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
// Should handle special characters in token and id
assert!(url.contains("token_with_special-chars"));
// Should handle special characters in id (no token in URL anymore)
assert!(url.contains("item-with-special_chars"));
}
@@ -383,9 +380,9 @@ mod tests {
// Backend generates full URL with credentials
let url = repo.get_image_url("item123", "Primary", None);
// URL is complete and ready to use
// URL is complete and ready to use (auth via header, not api_key)
assert!(url.starts_with("https://"));
assert!(url.contains("api_key="));
assert!(url.contains("/Items/item123/Images/Primary"));
// Frontend never constructs URLs directly
// Frontend only receives pre-constructed URLs from backend
@@ -408,7 +405,6 @@ mod tests {
assert!(url.contains("maxHeight=200"));
assert!(url.contains("quality=90"));
assert!(url.contains("tag=abc"));
assert!(url.contains("api_key=token"));
}
#[test]
@@ -423,8 +419,8 @@ mod tests {
let url = repo.get_image_url("item123", "Primary", Some(&options));
// Should only have api_key
assert!(url.contains("api_key=token"));
// Should have no query params (no api_key, no options)
assert!(!url.contains("?"));
assert!(!url.contains("maxWidth"));
assert!(!url.contains("maxHeight"));
assert!(!url.contains("quality"));
+167 -20
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
/// Error types for repository operations
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RepoError {
Network { message: String },
@@ -28,7 +28,7 @@ impl std::fmt::Display for RepoError {
impl std::error::Error for RepoError {}
/// Library (media collection)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub id: String,
@@ -39,7 +39,7 @@ pub struct Library {
}
/// User-specific data for an item (playback state, favorites, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserData {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -59,15 +59,17 @@ pub struct UserData {
}
/// Artist item with ID and name (for clickable artist links)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtistItem {
#[serde(alias = "Id")]
pub id: String,
#[serde(alias = "Name")]
pub name: String,
}
/// Person (cast/crew member) - for movies, series, and episodes
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Person {
/// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
@@ -95,7 +97,7 @@ pub struct Person {
}
/// Media item
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
pub id: String,
@@ -118,6 +120,7 @@ pub struct MediaItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "runTimeTicks")]
pub runtime_ticks: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>,
@@ -158,7 +161,7 @@ pub struct MediaItem {
}
/// Media stream information (audio, video, subtitle tracks)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaStream {
#[serde(rename = "type")]
@@ -175,7 +178,7 @@ pub struct MediaStream {
}
/// Media source information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaSource {
pub id: String,
@@ -194,7 +197,7 @@ pub struct MediaSource {
}
/// Search result with pagination
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
pub items: Vec<MediaItem>,
@@ -202,7 +205,7 @@ pub struct SearchResult {
}
/// Options for querying items
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GetItemsOptions {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -224,7 +227,7 @@ pub struct GetItemsOptions {
}
/// Options for search queries
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchOptions {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -236,7 +239,7 @@ pub struct SearchOptions {
}
/// Playback information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackInfo {
pub media_source_id: String,
@@ -247,15 +250,19 @@ pub struct PlaybackInfo {
}
/// Genre
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Genre {
pub id: String,
pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
}
/// Image type
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub enum ImageType {
Primary,
Backdrop,
@@ -277,7 +284,7 @@ impl ImageType {
}
/// Image options
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ImageOptions {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -331,6 +338,41 @@ impl MeaningfulContent for PlaybackInfo {
}
}
/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
/// needed for remove/reorder operations (distinct from the media item's ID)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistEntry {
/// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
pub playlist_item_id: String,
/// The underlying media item
#[serde(flatten)]
pub item: MediaItem,
}
/// Result of creating a playlist
///
/// @req: JA-019 - Get/create/update playlists
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCreatedResult {
pub id: String,
}
impl MeaningfulContent for Vec<PlaylistEntry> {
fn has_content(&self) -> bool {
!self.is_empty()
}
}
impl MeaningfulContent for PlaylistCreatedResult {
fn has_content(&self) -> bool {
!self.id.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -367,15 +409,21 @@ mod tests {
#[test]
fn test_artist_item_serialize() {
// Test that ArtistItem serializes to PascalCase for consistency
// ArtistItem serializes to camelCase for the frontend; it still accepts
// Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
let artist = ArtistItem {
id: "test-id".to_string(),
name: "Test Artist".to_string(),
};
let json = serde_json::to_string(&artist).expect("Failed to serialize");
assert!(json.contains(r#""Id":"test-id""#));
assert!(json.contains(r#""Name":"Test Artist""#));
assert!(json.contains(r#""id":"test-id""#));
assert!(json.contains(r#""name":"Test Artist""#));
let from_pascal: ArtistItem =
serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
assert_eq!(from_pascal.id, "x");
assert_eq!(from_pascal.name, "Y");
}
#[test]
@@ -550,7 +598,7 @@ mod tests {
assert!(result.is_ok());
let item = result.unwrap();
let people = item.people.expect("Expected people array");
let people = item.people.as_ref().expect("Expected people array");
assert_eq!(people.len(), 2);
assert_eq!(people[0].name, "John Doe");
assert_eq!(people[0].person_type, "Actor");
@@ -563,4 +611,103 @@ mod tests {
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
}
#[test]
fn test_playlist_entry_serialization() {
let entry = PlaylistEntry {
playlist_item_id: "entry-abc-123".to_string(),
item: MediaItem {
id: "track1".to_string(),
name: "Test Track".to_string(),
item_type: "Audio".to_string(),
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: Some(vec!["Artist One".to_string()]),
artist_items: None,
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: None,
media_streams: None,
media_sources: None,
people: None,
},
};
let json = serde_json::to_string(&entry).expect("Failed to serialize");
// playlistItemId is camelCase
assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
// Flattened MediaItem fields appear at top level
assert!(json.contains(r#""id":"track1""#));
assert!(json.contains(r#""name":"Test Track""#));
assert!(json.contains(r#""type":"Audio""#));
}
#[test]
fn test_playlist_created_result_serialization() {
let result = PlaylistCreatedResult {
id: "playlist-new-123".to_string(),
};
let json = serde_json::to_string(&result).expect("Failed to serialize");
assert!(json.contains(r#""id":"playlist-new-123""#));
}
#[test]
fn test_playlist_entry_meaningful_content() {
let empty: Vec<PlaylistEntry> = vec![];
assert!(!empty.has_content());
let non_empty = vec![PlaylistEntry {
playlist_item_id: "e1".to_string(),
item: MediaItem {
id: "1".to_string(),
name: "Track".to_string(),
item_type: "Audio".to_string(),
server_id: "s1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
index_number: None,
parent_index_number: None,
series_id: None,
series_name: None,
season_id: None,
season_name: None,
user_data: None,
media_streams: None,
media_sources: None,
people: None,
},
}];
assert!(non_empty.has_content());
}
}

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