Compare commits

..
43 Commits
Author SHA1 Message Date
dtourolle e1e50d51e0 Use different app logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m1s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m52s
Build & Release / Build Linux (push) Successful in 16m22s
Build & Release / Build Android (push) Successful in 19m13s
Build & Release / Create Release (push) Successful in 10s
2026-06-27 17:43:08 +02:00
dtourolle 7d7f27aa10 feat(library and playback): Support for serverside channel plugins and hls streaming 2026-06-27 17:25:57 +02:00
dtourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00
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
206 changed files with 11225 additions and 16135 deletions
+18 -5
View File
@@ -31,9 +31,9 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
src-tauri/target src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-host-
- name: Cache Node dependencies - name: Cache Node dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -66,6 +66,10 @@ jobs:
needs: test needs: test
container: container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest 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: steps:
- name: Checkout repository - name: Checkout repository
@@ -78,9 +82,9 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
src-tauri/target src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-android-
- name: Cache Node dependencies - name: Cache Node dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -99,6 +103,15 @@ jobs:
- name: Build frontend - name: Build frontend
run: bun run build run: bun run build
- name: Ensure Android NDK
run: |
if [ ! -d "$NDK_HOME" ]; then
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
fi
echo "Using NDK at $NDK_HOME"
ls "$NDK_HOME"
- name: Initialize Android project - name: Initialize Android project
run: | run: |
cd src-tauri cd src-tauri
@@ -109,7 +122,7 @@ jobs:
id: build id: build
run: | run: |
mkdir -p artifacts mkdir -p artifacts
bun run tauri android build --apk true bun run tauri android build --apk true --target aarch64
# Find the generated APK file # Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit) ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
+145 -142
View File
@@ -18,37 +18,40 @@ jobs:
test: test:
name: Run Tests name: Run Tests
runs-on: linux/amd64 runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/ key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | 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 - name: Install dependencies
run: bun install run: bun install
- name: Run frontend tests - name: Run frontend tests
run: bun run test --run run: |
bunx svelte-kit sync
bun run test --run
continue-on-error: false continue-on-error: false
- name: Run Rust tests - name: Run Rust tests
@@ -63,45 +66,32 @@ jobs:
name: Build Linux name: Build Linux
runs-on: linux/amd64 runs-on: linux/amd64
needs: test needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/ key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | 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 - name: Install dependencies
run: bun install run: bun install
@@ -135,74 +125,79 @@ jobs:
name: Build Android name: Build Android
runs-on: linux/amd64 runs-on: linux/amd64
needs: test 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: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 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 - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo-android- ${{ 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 - name: Install dependencies
run: bun install run: bun install
- name: Build for Android - name: Set app version from tag
run: bun run tauri android build run: |
env: REF="${GITHUB_REF#refs/tags/v}"
ANDROID_NDK_HOME: ${{ android.ndk-home }} VERSION="${REF#refs/heads/}"
ANDROID_SDK_ROOT: ${{ android.sdk-root }} # On non-tag runs keep whatever is in tauri.conf.json
ANDROID_HOME: ${{ android.sdk-root }} 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: | run: |
mkdir -p dist/android mkdir -p dist/android
# Copy APK APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
if [ -f "src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk" ]; then if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
cp src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk dist/android/jellytau-release.apk cp "$APK" dist/android/jellytau-release.apk
fi APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
# Copy AAB (Android App Bundle) if built echo "🔏 Verifying signature with $APKSIGNER"
if [ -f "src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" ]; then "$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
cp src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab dist/android/jellytau-release.aab
fi
ls -lah dist/android/ ls -lah dist/android/
- name: Upload Android build artifact - name: Upload Android build artifact
@@ -217,6 +212,8 @@ jobs:
runs-on: linux/amd64 runs-on: linux/amd64
needs: [build-linux, build-android] needs: [build-linux, build-android]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -243,23 +240,23 @@ jobs:
id: release_notes id: release_notes
run: | run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}" 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 "" >> release_notes.md
echo "### 📦 Downloads" >> release_notes.md echo "### Downloads" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "#### Linux" >> release_notes.md echo "#### Linux" >> release_notes.md
echo "- **AppImage** - Run directly on most Linux distributions" >> 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 "" >> release_notes.md
echo "#### Android" >> 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 "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
echo "" >> 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 "" >> release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 🔧 Installation" >> release_notes.md echo "### Installation" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "#### Linux (AppImage)" >> release_notes.md echo "#### Linux (AppImage)" >> release_notes.md
echo "\`\`\`bash" >> 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 "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
echo "- Play Store: Coming soon" >> release_notes.md echo "- Play Store: Coming soon" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 🐛 Known Issues" >> release_notes.md echo "### Known Issues" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 📝 Requirements" >> release_notes.md echo "### Requirements" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "**Linux:**" >> release_notes.md echo "**Linux:**" >> release_notes.md
echo "- 64-bit Linux system" >> release_notes.md echo "- 64-bit Linux system" >> release_notes.md
@@ -292,46 +289,52 @@ jobs:
echo "- 50MB free storage" >> release_notes.md echo "- 50MB free storage" >> release_notes.md
echo "" >> release_notes.md echo "" >> 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 - name: Publish Gitea release & upload assets
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') }}
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Upload to Gitea Releases AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -e
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
VERSION="${{ steps.tag_name.outputs.VERSION }}" 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" PAYLOAD=$(jq -n \
echo "" --arg tag "$VERSION" \
echo "Linux:" --arg name "JellyTau $VERSION" \
ls -lh artifacts/linux/ || echo "No Linux artifacts" --rawfile body release_notes.md \
echo "" --argjson pre "$PRE" \
echo "Android:" '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
ls -lh artifacts/android/ || echo "No Android artifacts"
echo ""
echo "✅ Release $VERSION is ready!"
echo "📄 Release notes saved to release_notes.md"
- name: Publish release notes echo "📦 Creating release $VERSION on $REPO"
run: | # -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
echo "## 🎉 Release Published" HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
echo "" -H "Authorization: token $TOKEN" \
echo "**Version:** ${{ steps.tag_name.outputs.VERSION }}" -H "Content-Type: application/json" \
echo "**Tag:** ${{ github.ref }}" -d "$PAYLOAD")
echo "" if [ "$HTTP" = "201" ]; then
echo "Artifacts:" RELEASE_ID=$(jq -r '.id' resp.json)
echo "- Linux artifacts in: artifacts/linux/" elif [ "$HTTP" = "409" ]; then
echo "- Android artifacts in: artifacts/android/" echo "️ Release $VERSION already exists; fetching its id to upload assets"
echo "" RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
echo "Visit the Release page to download files." -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"
+17 -7
View File
@@ -16,6 +16,8 @@ jobs:
validate-traces: validate-traces:
runs-on: linux/amd64 runs-on: linux/amd64
name: Check Requirement Traces name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -93,20 +95,28 @@ jobs:
echo "" echo ""
# Check each file # Check each file
MISSING_TRACES=0 # Pipe into the loop instead of a here-string (<<<) so this step works
while IFS= read -r file; do # 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 # Skip test files
if [[ "$file" == *".test."* ]]; then case "$file" in
continue *.test.*) continue ;;
fi esac
if [ -f "$file" ]; then if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file" echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1)) echo "$file" >> "$MISSING_FILE"
fi fi
fi fi
done <<< "$CHANGED" done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then if [ "$MISSING_TRACES" -gt 0 ]; then
echo "" echo ""
+2
View File
@@ -16,6 +16,8 @@ jobs:
traceability: traceability:
name: Validate Requirement Traces name: Validate Requirement Traces
runs-on: linux/amd64 runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout code - name: Checkout code
+11
View File
@@ -9,6 +9,11 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.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 output
/build /build
/dist /dist
@@ -47,3 +52,9 @@ vite.config.ts.timestamp-*
# Logs # Logs
logs logs
*.log *.log
# Android signing keystore (NEVER commit)
android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
+9 -3
View File
@@ -7,7 +7,8 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \ ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \ NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \ SDK_VERSION=36 \
BUILD_TOOLS_VERSION=35.0.0 \
RUST_BACKTRACE=1 \ RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \ PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo CARGO_HOME=/root/.cargo
@@ -20,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
ca-certificates \ ca-certificates \
unzip \ unzip \
jq \
openjdk-17-jdk-headless \ openjdk-17-jdk-headless \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
@@ -67,10 +69,14 @@ RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-1107
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \ mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true 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 \ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platform-tools" \
"platforms;android-$SDK_VERSION" \ "platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \ "build-tools;$BUILD_TOOLS_VERSION" \
"ndk;$NDK_VERSION" \ "ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true --channel=0 2>&1 | grep -v "Warning" || true
+31 -478
View File
@@ -1,336 +1,29 @@
# JellyTau <h1 align="center">
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
JellyTau
</h1>
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. 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
--- This project uses [bun](https://bun.sh) as its package manager.
# 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 | 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. Development Commands
```bash ```bash
# Activate Rust environment (fish shell) # Activate the Rust environment (fish shell)
source "$HOME/.cargo/env.fish" source "$HOME/.cargo/env.fish"
# Install dependencies # Install dependencies
bun install bun install
# Development # Run in development
bun run tauri dev bun run tauri dev
# Type checking # Type-check the frontend
bun run check bun run check
# Build for Linux # Build for Linux
@@ -340,168 +33,28 @@ bun run tauri build
bun run tauri android 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
``` | Topic | Location |
jellytau/ |-------|----------|
├── src/ # Svelte frontend | Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
│ ├── lib/ | Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
│ │ ├── api/ # Jellyfin API client (repository pattern) | Build & release process | [docs/build-release.md](docs/build-release.md) |
│ │ ├── components/ # UI components (player, library) | Docker builds | [docs/build/docker.md](docs/build/docker.md) |
│ │ └── stores/ # Svelte stores (auth, library, player, queue) | Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
│ └── routes/ # SvelteKit pages | Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
├── src-tauri/ # Rust backend | UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
│ ├── 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
```
--- ## 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. MIT
**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
+6 -3
View File
@@ -176,7 +176,10 @@ classDiagram
-server_url: String -server_url: String
-user_id: String -user_id: String
-access_token: String -access_token: String
-connectivity: Option~Arc~ConnectivityMonitor~~
+new() +new()
+with_connectivity()
-report_outcome()
} }
class OfflineRepository { class OfflineRepository {
@@ -192,10 +195,9 @@ classDiagram
class HybridRepository { class HybridRepository {
-online: Arc~OnlineRepository~ -online: Arc~OnlineRepository~
-offline: Arc~OfflineRepository~ -offline: Arc~OfflineRepository~
-connectivity: Arc~ConnectivityMonitor~
+new() +new()
-parallel_query() -parallel_race()
-has_meaningful_content() -cache_with_timeout()
} }
MediaRepository <|.. OnlineRepository MediaRepository <|.. OnlineRepository
@@ -214,6 +216,7 @@ classDiagram
- Returns cache result if it has meaningful content - Returns cache result if it has meaningful content
- Falls back to server result otherwise - Falls back to server result otherwise
- Background cache updates planned - 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): 2. **Handle-Based Resource Management** (`repository.rs` commands):
```rust ```rust
+9
View File
@@ -10,6 +10,7 @@ sequenceDiagram
participant Hybrid as HybridRepository participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite) participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP) participant Server as OnlineRepository (HTTP)
participant Conn as ConnectivityMonitor
UI->>Client: getItems(parentId) UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId}) Client->>Rust: invoke("repository_get_items", {handle, parentId})
@@ -20,6 +21,13 @@ sequenceDiagram
Hybrid->>Server: get_items() (no timeout) Hybrid->>Server: get_items() (no timeout)
end 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 alt Cache returns with content
Cache-->>Hybrid: Result with items Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result Hybrid-->>Rust: Return cache result
@@ -39,6 +47,7 @@ sequenceDiagram
- Cache wins if it has meaningful content - Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale - Automatic fallback to server if cache is empty/stale
- Background cache updates (planned) - 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 ## Playback Initiation Flow
+61 -26
View File
@@ -13,12 +13,13 @@ pub struct HttpClient {
} }
pub struct HttpConfig { pub struct HttpConfig {
pub base_url: String, pub timeout: Duration, // Default: 30s (large library queries can be slow)
pub timeout: Duration, // Default: 10s
pub max_retries: u32, // Default: 3 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 Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff) - Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors - Retries on: Network errors, 5xx server errors
@@ -38,39 +39,71 @@ pub enum ErrorKind {
**Location**: `src-tauri/src/connectivity/mod.rs` **Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor tracks server reachability with adaptive polling: 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 ```mermaid
flowchart TB flowchart TB
Monitor["ConnectivityMonitor"] --> Poller["Background Task"] Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
Poller --> Check{"Server<br/>Reachable?"} Monitor --> State{"is_server_reachable?"}
Check -->|"Yes"| Online["30s Interval"] State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
Check -->|"No"| Offline["5s Interval"] State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
Online --> Emit["Emit Events"] Probe -->|"reachable again"| Monitor
Offline --> Emit Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
Emit --> Frontend["Frontend Store"] 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:** **Features:**
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection) - **Traffic-driven**: Reachability follows the requests the user actually makes.
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events - **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- **Manual Marking**: Can mark reachable/unreachable based on API call results - **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state - **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
**Tauri Commands:** **Tauri Commands:**
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `connectivity_check_server` | Manual reachability check | | `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_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status | | `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start background monitoring | | `connectivity_start_monitoring` | Start the offline recovery probe |
| `connectivity_stop_monitoring` | Stop monitoring | | `connectivity_stop_monitoring` | Stop the probe |
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) | | `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) | | `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
**Frontend Integration:** **Frontend Integration:**
```typescript ```typescript
// TypeScript store listens to Rust events // 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) => { listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable); updateConnectivityState(event.payload.isReachable);
}); });
@@ -81,12 +114,14 @@ listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
The connectivity system provides resilience through multiple layers: The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff 1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Background reachability checks 2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
3. **Frontend Integration**: Offline mode detection and UI updates 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)) 4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:** **Design Principles:**
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication) - **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 Slow**: Retry network and 5xx errors with increasing delays - **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline - **Fail Slow**: Retry network and 5xx errors with increasing delays.
- **Event-Driven**: Frontend reacts to connectivity changes via events - **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.
@@ -15,6 +15,7 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
- **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`). - **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. - **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback. - **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. - **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. - **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.
@@ -79,26 +80,29 @@ flowchart TB
Core --> Storage Core --> Storage
Repository --> HttpClient Repository --> HttpClient
Repository --> DatabaseService Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end 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 ## Detailed Documentation
Each major subsystem is documented in its own file under [docs/architecture/](docs/architecture/): Each major subsystem is documented in its own file in this directory:
| Document | Contents | | Document | Contents |
|----------|----------| |----------|----------|
| [01 - Rust Backend](docs/architecture/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 | | [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](docs/architecture/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 | | [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](docs/architecture/03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control | | [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](docs/architecture/04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns | | [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](docs/architecture/05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization | | [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](docs/architecture/06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components | | [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](docs/architecture/07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture | | [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](docs/architecture/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 | | [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](docs/architecture/09-security.md) | Authentication token storage, secure storage module, network security, local data protection | | [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
--- ---
@@ -187,7 +191,7 @@ src/lib/
**What moved to Rust (~3,500 lines of business logic):** **What moved to Rust (~3,500 lines of business logic):**
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff 1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
2. **Connectivity Monitor** (301 lines) - Adaptive polling, event emission 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 3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
4. **Database Service** - Async wrapper preventing UI freezing 4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination 5. **Playback Mode** (303 lines) - Local/remote transfer coordination
Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

View File
+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
View File
-10227
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
@@ -18,7 +19,7 @@
"test:rust": "./scripts/test-rust.sh", "test:rust": "./scripts/test-rust.sh",
"android:build": "./scripts/build-android.sh", "android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release", "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 && npm install && npm run build", "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:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh", "android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh", "android:check": "./scripts/check-android.sh",
+1 -1
View File
@@ -94,7 +94,7 @@ The traceability system is integrated with Gitea Actions CI/CD:
For details, see: For details, see:
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation - [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
- [TRACES Quick Reference](../traces-quick-ref.md) - Quick guide for adding TRACES - [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
## Utility Scripts ## Utility Scripts
+6 -2
View File
@@ -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 TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g; const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
@@ -45,7 +49,7 @@ function extractRequirementIds(tracesString: string): string[] {
} }
function getAllSourceFiles(): string[] { function getAllSourceFiles(): string[] {
const baseDir = "/home/dtourolle/Development/JellyTau"; const baseDir = BASE_DIR;
const patterns = ["src", "src-tauri/src"]; const patterns = ["src", "src-tauri/src"];
const files: string[] = []; const files: string[] = [];
@@ -101,7 +105,7 @@ function extractTraces(): TracesData {
}; };
let totalTraces = 0; let totalTraces = 0;
const baseDir = "/home/dtourolle/Development/JellyTau"; const baseDir = BASE_DIR;
const files = getAllSourceFiles(); const files = getAllSourceFiles();
+10
View File
@@ -31,4 +31,14 @@ for kt_file in "$SOURCE_DIR"/*.kt; do
fi fi
done 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" echo "✓ Android sources synced successfully"
-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. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -1745,7 +1751,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.62.2", "windows-core",
] ]
[[package]] [[package]]
@@ -2011,14 +2017,18 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"specta",
"specta-typescript",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-specta",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-rusqlite", "tokio-rusqlite",
"tokio-util", "tokio-util",
"urlencoding",
"uuid", "uuid",
] ]
@@ -2795,6 +2805,12 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -3933,6 +3949,51 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -4092,7 +4153,7 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
"url", "url",
"windows", "windows",
"windows-core 0.61.2", "windows-core",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
] ]
@@ -4149,6 +4210,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_repr", "serde_repr",
"serialize-to-javascript", "serialize-to-javascript",
"specta",
"swift-rs", "swift-rs",
"tauri-build", "tauri-build",
"tauri-macros", "tauri-macros",
@@ -4337,6 +4399,34 @@ dependencies = [
"wry", "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]] [[package]]
name = "tauri-utils" name = "tauri-utils"
version = "2.8.1" version = "2.8.1"
@@ -4870,6 +4960,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@@ -5143,7 +5239,7 @@ dependencies = [
"webview2-com-macros", "webview2-com-macros",
"webview2-com-sys", "webview2-com-sys",
"windows", "windows",
"windows-core 0.61.2", "windows-core",
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
] ]
@@ -5167,7 +5263,7 @@ checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
dependencies = [ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"windows", "windows",
"windows-core 0.61.2", "windows-core",
] ]
[[package]] [[package]]
@@ -5223,7 +5319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [ dependencies = [
"windows-collections", "windows-collections",
"windows-core 0.61.2", "windows-core",
"windows-future", "windows-future",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-numerics", "windows-numerics",
@@ -5235,7 +5331,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [ dependencies = [
"windows-core 0.61.2", "windows-core",
] ]
[[package]] [[package]]
@@ -5247,21 +5343,8 @@ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result",
"windows-strings 0.4.2", "windows-strings",
]
[[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",
] ]
[[package]] [[package]]
@@ -5270,7 +5353,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [ dependencies = [
"windows-core 0.61.2", "windows-core",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-threading", "windows-threading",
] ]
@@ -5315,7 +5398,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [ dependencies = [
"windows-core 0.61.2", "windows-core",
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
@@ -5328,15 +5411,6 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@@ -5346,15 +5420,6 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"
@@ -5750,7 +5815,7 @@ dependencies = [
"webkit2gtk-sys", "webkit2gtk-sys",
"webview2-com", "webview2-com",
"windows", "windows",
"windows-core 0.61.2", "windows-core",
"windows-version", "windows-version",
"x11-dl", "x11-dl",
] ]
+4
View File
@@ -33,6 +33,7 @@ rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] } tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7" tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
urlencoding = "2"
futures-util = "0.3" futures-util = "0.3"
async-trait = "0.1" async-trait = "0.1"
@@ -50,6 +51,9 @@ sha2 = "0.10"
getrandom = "0.2" getrandom = "0.2"
log = "0.4" log = "0.4"
env_logger = "0.11" 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 # Linux-specific dependencies
[target.'cfg(target_os = "linux")'.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
}
}
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */ /** Current media ID being played */
private var currentMediaId: String? = null 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 */ /** Current media metadata for notification updates */
private var currentTitle: String = "" private var currentTitle: String = ""
private var currentArtist: String = "" private var currentArtist: String = ""
@@ -171,6 +180,12 @@ class JellyTauPlayer(private val appContext: Context) {
// Create ExoPlayer with audio focus handling // Create ExoPlayer with audio focus handling
exoPlayer = ExoPlayer.Builder(appContext) exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) .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() .build()
// Set up player listener // Set up player listener
@@ -202,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed // Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended") android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates() 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 -> { Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...") android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@@ -316,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) { fun load(url: String, mediaId: String) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare() exoPlayer.prepare()
@@ -546,6 +570,7 @@ class JellyTauPlayer(private val appContext: Context) {
) { ) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates // Store metadata for notification updates
currentTitle = title currentTitle = title
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+8 -7
View File
@@ -10,8 +10,9 @@ use crate::connectivity::ConnectivityMonitor;
pub use session_verifier::SessionVerifier; pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin /// Server information returned from Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[specta(rename = "AuthServerInfo")]
pub struct ServerInfo { pub struct ServerInfo {
pub name: String, pub name: String,
pub version: String, pub version: String,
@@ -21,7 +22,7 @@ pub struct ServerInfo {
} }
/// User information /// User information
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct User { pub struct User {
pub id: String, pub id: String,
@@ -31,7 +32,7 @@ pub struct User {
} }
/// Authentication result /// Authentication result
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AuthResult { pub struct AuthResult {
pub user: User, pub user: User,
@@ -40,7 +41,7 @@ pub struct AuthResult {
} }
/// Active session for restoration /// Active session for restoration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Session { pub struct Session {
pub user_id: String, pub user_id: String,
@@ -55,7 +56,7 @@ pub struct Session {
// Jellyfin API response types (PascalCase from server) // Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)] #[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
struct PublicSystemInfo { struct PublicSystemInfo {
server_name: String, server_name: String,
@@ -63,7 +64,7 @@ struct PublicSystemInfo {
id: String, id: String,
} }
#[derive(Debug, Deserialize)] #[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse { struct AuthenticateByNameResponse {
user: JellyfinUser, user: JellyfinUser,
@@ -71,7 +72,7 @@ struct AuthenticateByNameResponse {
server_id: String, server_id: String,
} }
#[derive(Debug, Deserialize)] #[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "PascalCase")]
struct JellyfinUser { struct JellyfinUser {
id: String, id: String,
+10
View File
@@ -12,6 +12,7 @@ pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerif
/// Initialize the auth manager (call on app startup) /// Initialize the auth manager (call on app startup)
/// Restores session from storage if available /// Restores session from storage if available
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_initialize( pub async fn auth_initialize(
auth_manager: State<'_, AuthManagerWrapper>, auth_manager: State<'_, AuthManagerWrapper>,
database: State<'_, crate::commands::DatabaseWrapper>, database: State<'_, crate::commands::DatabaseWrapper>,
@@ -61,6 +62,7 @@ pub async fn auth_initialize(
/// Connect to a Jellyfin server and get server info /// Connect to a Jellyfin server and get server info
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_connect_to_server( pub async fn auth_connect_to_server(
server_url: String, server_url: String,
auth_manager: State<'_, AuthManagerWrapper>, auth_manager: State<'_, AuthManagerWrapper>,
@@ -70,6 +72,7 @@ pub async fn auth_connect_to_server(
/// Login with username and password /// Login with username and password
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_login( pub async fn auth_login(
server_url: String, server_url: String,
username: String, username: String,
@@ -100,6 +103,7 @@ pub async fn auth_login(
/// Verify current session /// Verify current session
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_verify_session( pub async fn auth_verify_session(
server_url: String, server_url: String,
user_id: String, user_id: String,
@@ -118,6 +122,7 @@ pub async fn auth_verify_session(
/// Logout (clear session and call Jellyfin logout endpoint) /// Logout (clear session and call Jellyfin logout endpoint)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_logout( pub async fn auth_logout(
server_url: String, server_url: String,
access_token: String, access_token: String,
@@ -143,6 +148,7 @@ pub async fn auth_logout(
/// Get current session /// Get current session
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_get_session( pub async fn auth_get_session(
auth_manager: State<'_, AuthManagerWrapper>, auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<Option<Session>, String> { ) -> Result<Option<Session>, String> {
@@ -151,6 +157,7 @@ pub async fn auth_get_session(
/// Set current session (for restoration from storage) /// Set current session (for restoration from storage)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_set_session( pub async fn auth_set_session(
session: Option<Session>, session: Option<Session>,
auth_manager: State<'_, AuthManagerWrapper>, auth_manager: State<'_, AuthManagerWrapper>,
@@ -170,6 +177,7 @@ pub async fn auth_set_session(
/// Start background session verification /// Start background session verification
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_start_verification( pub async fn auth_start_verification(
device_id: String, device_id: String,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@@ -198,6 +206,7 @@ pub async fn auth_start_verification(
/// Stop background session verification /// Stop background session verification
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_stop_verification( pub async fn auth_stop_verification(
session_verifier: State<'_, SessionVerifierWrapper>, session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -212,6 +221,7 @@ pub async fn auth_stop_verification(
/// Re-authenticate with password (when session expired) /// Re-authenticate with password (when session expired)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn auth_reauthenticate( pub async fn auth_reauthenticate(
password: String, password: String,
device_id: String, device_id: String,
+7
View File
@@ -7,6 +7,7 @@ pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMon
/// Check if the server is currently reachable /// Check if the server is currently reachable
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_check_server( pub async fn connectivity_check_server(
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<bool, String> { ) -> Result<bool, String> {
@@ -16,6 +17,7 @@ pub async fn connectivity_check_server(
/// Set the server URL and trigger an immediate check /// Set the server URL and trigger an immediate check
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_set_server_url( pub async fn connectivity_set_server_url(
url: String, url: String,
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
@@ -27,6 +29,7 @@ pub async fn connectivity_set_server_url(
/// Get the current connectivity status /// Get the current connectivity status
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_get_status( pub async fn connectivity_get_status(
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<ConnectivityStatus, String> { ) -> Result<ConnectivityStatus, String> {
@@ -36,6 +39,7 @@ pub async fn connectivity_get_status(
/// Start monitoring connectivity with adaptive polling /// Start monitoring connectivity with adaptive polling
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_start_monitoring( pub async fn connectivity_start_monitoring(
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -46,6 +50,7 @@ pub async fn connectivity_start_monitoring(
/// Stop monitoring connectivity /// Stop monitoring connectivity
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_stop_monitoring( pub async fn connectivity_stop_monitoring(
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -56,6 +61,7 @@ pub async fn connectivity_stop_monitoring(
/// Mark the server as reachable (called after successful API calls) /// Mark the server as reachable (called after successful API calls)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_mark_reachable( pub async fn connectivity_mark_reachable(
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -66,6 +72,7 @@ pub async fn connectivity_mark_reachable(
/// Mark the server as unreachable (called after failed API calls) /// Mark the server as unreachable (called after failed API calls)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn connectivity_mark_unreachable( pub async fn connectivity_mark_unreachable(
error: Option<String>, error: Option<String>,
state: State<'_, ConnectivityMonitorWrapper>, state: State<'_, ConnectivityMonitorWrapper>,
+5
View File
@@ -16,6 +16,7 @@ use crate::utils::conversions::{
/// # Returns /// # Returns
/// Formatted string like "3:45" or "12:09" /// Formatted string like "3:45" or "12:09"
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn format_time_seconds(seconds: f64) -> String { pub fn format_time_seconds(seconds: f64) -> String {
format_time(seconds) format_time(seconds)
} }
@@ -32,6 +33,7 @@ pub fn format_time_seconds(seconds: f64) -> String {
/// # Returns /// # Returns
/// Formatted string like "1:23:45" or "3:45" /// Formatted string like "1:23:45" or "3:45"
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn format_time_seconds_long(seconds: f64) -> String { pub fn format_time_seconds_long(seconds: f64) -> String {
format_time_long(seconds) format_time_long(seconds)
} }
@@ -44,6 +46,7 @@ pub fn format_time_seconds_long(seconds: f64) -> String {
/// # Returns /// # Returns
/// Time in seconds /// Time in seconds
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 { pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
ticks_to_seconds(ticks) ticks_to_seconds(ticks)
} }
@@ -57,6 +60,7 @@ pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
/// # Returns /// # Returns
/// Progress as percentage (0.0 to 100.0) /// Progress as percentage (0.0 to 100.0)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn calc_progress(position: f64, duration: f64) -> f64 { pub fn calc_progress(position: f64, duration: f64) -> f64 {
calculate_progress(position, duration) calculate_progress(position, duration)
} }
@@ -69,6 +73,7 @@ pub fn calc_progress(position: f64, duration: f64) -> f64 {
/// # Returns /// # Returns
/// Normalized volume (0.0 to 1.0) /// Normalized volume (0.0 to 1.0)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn convert_percent_to_volume(percent: f64) -> f64 { pub fn convert_percent_to_volume(percent: f64) -> f64 {
percent_to_volume(percent) percent_to_volume(percent)
} }
+2
View File
@@ -23,6 +23,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// ///
/// TRACES: UR-009 | DR-011 /// TRACES: UR-009 | DR-011
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> { pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; 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 /// TRACES: UR-009 | DR-011
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> { pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; 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)
}
+4 -1
View File
@@ -7,7 +7,7 @@ use tauri::State;
use super::DatabaseWrapper; use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(Debug, Serialize, Deserialize)] #[derive(specta::Type, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OfflineItem { pub struct OfflineItem {
pub id: String, pub id: String,
@@ -22,6 +22,7 @@ pub struct OfflineItem {
/// Check if an item is available offline /// Check if an item is available offline
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn offline_is_available( pub async fn offline_is_available(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
item_id: String, item_id: String,
@@ -46,6 +47,7 @@ pub async fn offline_is_available(
/// Get all offline items for a user /// Get all offline items for a user
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn offline_get_items( pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -84,6 +86,7 @@ pub async fn offline_get_items(
/// Search offline items /// Search offline items
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn offline_search( pub async fn offline_search(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
+28 -4
View File
@@ -8,6 +8,7 @@ pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
/// Get the current playback mode /// Get the current playback mode
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn playback_mode_get_current( pub fn playback_mode_get_current(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<PlaybackMode, String> { ) -> Result<PlaybackMode, String> {
@@ -16,6 +17,7 @@ pub fn playback_mode_get_current(
/// Set the playback mode (internal/testing use) /// Set the playback mode (internal/testing use)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn playback_mode_set( pub fn playback_mode_set(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
mode: PlaybackMode, mode: PlaybackMode,
@@ -26,6 +28,7 @@ pub fn playback_mode_set(
/// Check if currently transferring between playback modes /// Check if currently transferring between playback modes
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn playback_mode_is_transferring( pub fn playback_mode_is_transferring(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<bool, String> { ) -> Result<bool, String> {
@@ -34,15 +37,34 @@ pub fn playback_mode_is_transferring(
/// Transfer playback from local device to a remote Jellyfin session /// Transfer playback from local device to a remote Jellyfin session
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_mode_transfer_to_remote( pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String, session_id: String,
position: Option<f64>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!( log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}", "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id session_id,
position
); );
manager.0.transfer_to_remote(session_id).await manager.0.transfer_to_remote(session_id, position).await
}
/// Set the transferring flag on the playback mode manager.
///
/// Used by the frontend remote->local flow to mark the whole two-step sequence
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
/// casting back to the remote session it's leaving. Always pair `true` with a
/// later `false` (including on error) so the flag can't stick.
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_set_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
transferring: bool,
) -> Result<(), String> {
manager.0.set_transferring(transferring);
Ok(())
} }
/// Transfer playback from remote session back to local device /// Transfer playback from remote session back to local device
@@ -51,6 +73,7 @@ pub async fn playback_mode_transfer_to_remote(
/// - current_item_id: The Jellyfin item ID currently playing on remote /// - current_item_id: The Jellyfin item ID currently playing on remote
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms) /// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_mode_transfer_to_local( pub async fn playback_mode_transfer_to_local(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
current_item_id: String, current_item_id: String,
@@ -69,6 +92,7 @@ pub async fn playback_mode_transfer_to_local(
/// Get remote session status (for polling position/duration) /// Get remote session status (for polling position/duration)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_mode_get_remote_status( pub async fn playback_mode_get_remote_status(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
player: State<'_, crate::commands::PlayerStateWrapper>, player: State<'_, crate::commands::PlayerStateWrapper>,
@@ -119,7 +143,7 @@ pub async fn playback_mode_get_remote_status(
} }
/// Remote session status for UI updates /// Remote session status for UI updates
#[derive(serde::Serialize)] #[derive(specta::Type, serde::Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RemoteSessionStatus { pub struct RemoteSessionStatus {
pub position: f64, pub position: f64,
@@ -24,6 +24,7 @@ pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>
/// Initialize playback reporter (called after login) /// Initialize playback reporter (called after login)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_reporter_init( pub async fn playback_reporter_init(
reporter_wrapper: State<'_, PlaybackReporterWrapper>, reporter_wrapper: State<'_, PlaybackReporterWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
@@ -66,6 +67,7 @@ pub async fn playback_reporter_init(
/// Destroy playback reporter (called on logout) /// Destroy playback reporter (called on logout)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_reporter_destroy( pub async fn playback_reporter_destroy(
reporter_wrapper: State<'_, PlaybackReporterWrapper>, reporter_wrapper: State<'_, PlaybackReporterWrapper>,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -76,6 +78,7 @@ pub async fn playback_reporter_destroy(
/// Report playback start /// Report playback start
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_report_start( pub async fn playback_report_start(
reporter: State<'_, PlaybackReporterWrapper>, reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>, connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -110,6 +113,7 @@ pub async fn playback_report_start(
/// Report playback progress /// Report playback progress
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_report_progress( pub async fn playback_report_progress(
reporter: State<'_, PlaybackReporterWrapper>, reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>, connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -138,6 +142,7 @@ pub async fn playback_report_progress(
/// Report playback stopped /// Report playback stopped
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_report_stopped( pub async fn playback_report_stopped(
reporter: State<'_, PlaybackReporterWrapper>, reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>, connectivity: State<'_, ConnectivityMonitorWrapper>,
@@ -164,6 +169,7 @@ pub async fn playback_report_stopped(
/// Mark item as played /// Mark item as played
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playback_mark_played( pub async fn playback_mark_played(
reporter: State<'_, PlaybackReporterWrapper>, reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>, connectivity: State<'_, ConnectivityMonitorWrapper>,
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))
}
+221
View File
@@ -0,0 +1,221 @@
//! 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;
use crate::jellyfin::client::LmsSyncGroup;
/// 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())
}
}
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
//
// The frontend addresses LMS players by MAC address, which it derives from a
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
// plugin REST API via the configured JellyfinClient.
/// List current LMS sync groups.
#[tauri::command]
#[specta::specta]
pub async fn lms_get_sync_groups(
player: State<'_, PlayerStateWrapper>,
) -> Result<Vec<LmsSyncGroup>, String> {
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.lms_get_sync_groups().await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
/// `slave_macs` zones join it in sync.
#[tauri::command]
#[specta::specta]
pub async fn lms_create_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_create_sync_group(&master_mac, slave_macs).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Remove a single LMS zone from its sync group (decouple one player).
#[tauri::command]
#[specta::specta]
pub async fn lms_unsync_player(
player: State<'_, PlayerStateWrapper>,
mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Decoupling zone {}", mac);
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.lms_unsync_player(&mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
#[tauri::command]
#[specta::specta]
pub async fn lms_dissolve_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
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.lms_dissolve_sync_group(&master_mac).await
} 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(())
}
+7
View File
@@ -11,6 +11,7 @@ use super::repository::RepositoryManagerWrapper;
/// Create a new playlist /// Create a new playlist
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_create( pub async fn playlist_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -27,6 +28,7 @@ pub async fn playlist_create(
/// Delete a playlist /// Delete a playlist
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_delete( pub async fn playlist_delete(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -41,6 +43,7 @@ pub async fn playlist_delete(
/// Rename a playlist /// Rename a playlist
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_rename( pub async fn playlist_rename(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -56,6 +59,7 @@ pub async fn playlist_rename(
/// Get playlist items with PlaylistItemId /// Get playlist items with PlaylistItemId
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_get_items( pub async fn playlist_get_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -70,6 +74,7 @@ pub async fn playlist_get_items(
/// Add items to a playlist /// Add items to a playlist
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_add_items( pub async fn playlist_add_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -85,6 +90,7 @@ pub async fn playlist_add_items(
/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs) /// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_remove_items( pub async fn playlist_remove_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -100,6 +106,7 @@ pub async fn playlist_remove_items(
/// Move a playlist item to a new position /// Move a playlist item to a new position
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn playlist_move_item( pub async fn playlist_move_item(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
+159 -10
View File
@@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use log::{debug, error, info}; use log::{debug, error, info, warn};
use tauri::State; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid; use uuid::Uuid;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
@@ -48,9 +49,11 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Create a new repository instance /// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository /// Returns a handle (UUID) for accessing the repository
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_create( pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>, db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String, server_url: String,
user_id: String, user_id: String,
access_token: String, access_token: String,
@@ -67,9 +70,18 @@ pub async fn repository_create(
})?; })?;
debug!("[REPO] HTTP client created successfully"); 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..."); 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"); debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service // Create offline repository with async-safe database service
@@ -108,6 +120,7 @@ pub async fn repository_create(
/// Destroy a repository instance /// Destroy a repository instance
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_destroy( pub async fn repository_destroy(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -118,6 +131,7 @@ pub async fn repository_destroy(
/// Get libraries /// Get libraries
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_libraries( pub async fn repository_get_libraries(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -138,6 +152,7 @@ pub async fn repository_get_libraries(
/// Get items in a container (library, folder, album, etc.) /// Get items in a container (library, folder, album, etc.)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_items( pub async fn repository_get_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -152,6 +167,7 @@ pub async fn repository_get_items(
/// Get a single item by ID /// Get a single item by ID
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_item( pub async fn repository_get_item(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -165,6 +181,7 @@ pub async fn repository_get_item(
/// Get latest items in a library /// Get latest items in a library
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_latest_items( pub async fn repository_get_latest_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -179,6 +196,7 @@ pub async fn repository_get_latest_items(
/// Get resume items (continue watching/listening) /// Get resume items (continue watching/listening)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_resume_items( pub async fn repository_get_resume_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -201,6 +219,7 @@ pub async fn repository_get_resume_items(
/// Get next up episodes /// Get next up episodes
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_next_up_episodes( pub async fn repository_get_next_up_episodes(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -215,6 +234,7 @@ pub async fn repository_get_next_up_episodes(
/// Get recently played audio /// Get recently played audio
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_recently_played_audio( pub async fn repository_get_recently_played_audio(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -228,6 +248,7 @@ pub async fn repository_get_recently_played_audio(
/// Get resume movies /// Get resume movies
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_resume_movies( pub async fn repository_get_resume_movies(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -239,8 +260,24 @@ pub async fn repository_get_resume_movies(
.map_err(|e| format!("{:?}", e)) .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 /// Get genres for a library
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_genres( pub async fn repository_get_genres(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -252,22 +289,78 @@ pub async fn repository_get_genres(
.map_err(|e| format!("{:?}", e)) .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 /// Search for items
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_search( pub async fn repository_search(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
query: String, query: String,
options: Option<SearchOptions>, options: Option<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> { ) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?; 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 .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 /// Get playback info for an item
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_playback_info( pub async fn repository_get_playback_info(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -281,6 +374,7 @@ pub async fn repository_get_playback_info(
/// Get video stream URL with optional seeking support /// Get video stream URL with optional seeking support
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_video_stream_url( pub async fn repository_get_video_stream_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -303,6 +397,7 @@ pub async fn repository_get_video_stream_url(
/// Get audio stream URL for a track /// Get audio stream URL for a track
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_audio_stream_url( pub async fn repository_get_audio_stream_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -315,8 +410,52 @@ pub async fn repository_get_audio_stream_url(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get Live TV channels (broadcast / IPTV) for browsing
#[tauri::command]
#[specta::specta]
pub async fn repository_get_live_tv_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_live_tv_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Get the root list of plugin "Channels"
#[tauri::command]
#[specta::specta]
pub async fn repository_get_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Open a live stream for a Live TV channel / live item
#[tauri::command]
#[specta::specta]
pub async fn repository_open_live_stream(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<LiveStreamInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.open_live_stream(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start /// Report playback start
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_start( pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -331,6 +470,7 @@ pub async fn repository_report_playback_start(
/// Report playback progress /// Report playback progress
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_progress( pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -345,6 +485,7 @@ pub async fn repository_report_playback_progress(
/// Report playback stopped /// Report playback stopped
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_report_playback_stopped( pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -359,6 +500,7 @@ pub async fn repository_report_playback_stopped(
/// Get image URL for an item /// Get image URL for an item
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn repository_get_image_url( pub fn repository_get_image_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -372,6 +514,7 @@ pub fn repository_get_image_url(
/// Get subtitle URL for a media item /// Get subtitle URL for a media item
#[tauri::command] #[tauri::command]
#[specta::specta]
#[allow(dead_code)] #[allow(dead_code)]
pub fn repository_get_subtitle_url( pub fn repository_get_subtitle_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
@@ -387,6 +530,7 @@ pub fn repository_get_subtitle_url(
/// Get video download URL with quality preset /// Get video download URL with quality preset
#[tauri::command] #[tauri::command]
#[specta::specta]
#[allow(dead_code)] #[allow(dead_code)]
pub fn repository_get_video_download_url( pub fn repository_get_video_download_url(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
@@ -401,6 +545,7 @@ pub fn repository_get_video_download_url(
/// Mark an item as favorite /// Mark an item as favorite
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_mark_favorite( pub async fn repository_mark_favorite(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -414,6 +559,7 @@ pub async fn repository_mark_favorite(
/// Unmark an item as favorite /// Unmark an item as favorite
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_unmark_favorite( pub async fn repository_unmark_favorite(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -427,6 +573,7 @@ pub async fn repository_unmark_favorite(
/// Get person details /// Get person details
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_person( pub async fn repository_get_person(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -440,6 +587,7 @@ pub async fn repository_get_person(
/// Get items by person (actor, director, etc.) /// Get items by person (actor, director, etc.)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_items_by_person( pub async fn repository_get_items_by_person(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -454,6 +602,7 @@ pub async fn repository_get_items_by_person(
/// Get similar/related items for a media item /// Get similar/related items for a media item
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn repository_get_similar_items( pub async fn repository_get_similar_items(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
@@ -473,16 +622,16 @@ mod tests {
#[test] #[test]
fn test_repository_manager_creation() { fn test_repository_manager_creation() {
let manager = RepositoryManager::new(); let manager = RepositoryManager::new();
// Should be able to create RepositoryManager without panicking // A freshly created manager holds no repositories
assert_eq!(std::mem::size_of::<RepositoryManager>() > 0, true); assert!(manager.get("any-handle").is_none());
} }
#[test] #[test]
fn test_repository_manager_wrapper_structure() { fn test_repository_manager_wrapper_structure() {
let manager = RepositoryManager::new(); let manager = RepositoryManager::new();
let wrapper = RepositoryManagerWrapper(manager); let wrapper = RepositoryManagerWrapper(manager);
// Verify wrapper holds the manager // The wrapper exposes the underlying manager, which starts empty
assert_eq!(std::mem::size_of::<RepositoryManagerWrapper>() > 0, true); assert!(wrapper.0.get("any-handle").is_none());
} }
#[test] #[test]
+2
View File
@@ -10,6 +10,7 @@ pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
/// Set polling frequency hint based on UI state /// Set polling frequency hint based on UI state
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn sessions_set_polling_hint( pub fn sessions_set_polling_hint(
poller: State<'_, SessionPollerWrapper>, poller: State<'_, SessionPollerWrapper>,
hint: String, hint: String,
@@ -27,6 +28,7 @@ pub fn sessions_set_polling_hint(
/// Manually trigger a session poll (for refresh button) /// Manually trigger a session poll (for refresh button)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sessions_poll_now( pub async fn sessions_poll_now(
poller: State<'_, SessionPollerWrapper>, poller: State<'_, SessionPollerWrapper>,
) -> Result<Vec<SessionInfo>, String> { ) -> Result<Vec<SessionInfo>, String> {
@@ -1,7 +1,6 @@
//! Tauri commands for database/storage operations //! Tauri commands for database/storage operations
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -10,12 +9,19 @@ use tauri::State;
use crate::credentials::CredentialStore; use crate::credentials::CredentialStore;
use crate::storage::Database; use crate::storage::Database;
use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::thumbnail::{ThumbnailCache, ThumbnailCacheStats, ThumbnailWorker}; use crate::thumbnail::ThumbnailCache;
use crate::repository::types::{ImageType, ImageOptions};
use crate::repository::MediaRepository;
use super::SmartCacheWrapper; use super::SmartCacheWrapper;
// Cohesive command clusters in their own submodules, re-exported so the command
// names remain at `commands::storage::*` (invoke_handler unchanged).
mod people;
mod series_prefs;
mod thumbnails;
pub use people::*;
pub use series_prefs::*;
pub use thumbnails::*;
/// Wrapper for thread-safe database access /// Wrapper for thread-safe database access
pub struct DatabaseWrapper(pub Mutex<Database>); pub struct DatabaseWrapper(pub Mutex<Database>);
@@ -26,7 +32,7 @@ pub struct CredentialStoreWrapper(pub Mutex<CredentialStore>);
pub struct ThumbnailCacheWrapper(pub Arc<ThumbnailCache>); pub struct ThumbnailCacheWrapper(pub Arc<ThumbnailCache>);
/// Server info returned to frontend /// Server info returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo { pub struct ServerInfo {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -35,7 +41,7 @@ pub struct ServerInfo {
} }
/// User info returned to frontend /// User info returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserInfo { pub struct UserInfo {
pub id: String, pub id: String,
@@ -45,7 +51,7 @@ pub struct UserInfo {
} }
/// Active session info (for session restoration) /// Active session info (for session restoration)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ActiveSession { pub struct ActiveSession {
pub user_id: String, pub user_id: String,
@@ -57,7 +63,7 @@ pub struct ActiveSession {
} }
/// Security status info /// Security status info
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SecurityStatus { pub struct SecurityStatus {
pub using_keyring: bool, pub using_keyring: bool,
@@ -66,6 +72,7 @@ pub struct SecurityStatus {
/// Initialize the database and run migrations /// Initialize the database and run migrations
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> { pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Ok(database.path().to_string_lossy().to_string()) Ok(database.path().to_string_lossy().to_string())
@@ -73,6 +80,7 @@ pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
/// Get storage directory path (parent directory of the database file) /// Get storage directory path (parent directory of the database file)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> { pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
let db_path = database.path(); let db_path = database.path();
@@ -86,6 +94,7 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
/// Get database file size in bytes /// Get database file size in bytes
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, String> { pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, String> {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
Ok(database.file_size()) Ok(database.file_size())
@@ -93,6 +102,7 @@ pub fn storage_get_size(db: State<DatabaseWrapper>) -> Result<Option<u64>, Strin
/// Get security status (keyring vs encrypted file fallback) /// Get security status (keyring vs encrypted file fallback)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn storage_get_security_status( pub fn storage_get_security_status(
creds: State<CredentialStoreWrapper>, creds: State<CredentialStoreWrapper>,
) -> Result<SecurityStatus, String> { ) -> Result<SecurityStatus, String> {
@@ -111,6 +121,7 @@ pub fn storage_get_security_status(
/// Save a server connection /// Save a server connection
/// Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users /// Uses INSERT ... ON CONFLICT to avoid triggering CASCADE DELETE on users
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_save_server( pub async fn storage_save_server(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
id: String, id: String,
@@ -148,6 +159,7 @@ pub async fn storage_save_server(
/// Get all saved servers /// Get all saved servers
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> { pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> {
let db_service = { let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?; let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -173,6 +185,7 @@ pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<S
/// Delete a server and all associated data /// Delete a server and all associated data
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_delete_server( pub async fn storage_delete_server(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>, creds: State<'_, CredentialStoreWrapper>,
@@ -215,6 +228,7 @@ pub async fn storage_delete_server(
/// Save a user account (token stored in secure storage, not database) /// Save a user account (token stored in secure storage, not database)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_save_user( pub async fn storage_save_user(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>, creds: State<'_, CredentialStoreWrapper>,
@@ -292,6 +306,7 @@ pub async fn storage_save_user(
/// Get users for a server /// Get users for a server
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_users( pub async fn storage_get_users(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
server_id: String, server_id: String,
@@ -324,6 +339,7 @@ pub async fn storage_get_users(
/// Set a user as active (and deactivate all other users globally) /// Set a user as active (and deactivate all other users globally)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_set_active_user( pub async fn storage_set_active_user(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -372,6 +388,7 @@ pub async fn storage_set_active_user(
/// Get the active user for a server /// Get the active user for a server
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_active_user( pub async fn storage_get_active_user(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
server_id: String, server_id: String,
@@ -402,6 +419,7 @@ pub async fn storage_get_active_user(
/// Get the active session (user + server + token) for session restoration /// Get the active session (user + server + token) for session restoration
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_active_session( pub async fn storage_get_active_session(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>, creds: State<'_, CredentialStoreWrapper>,
@@ -477,6 +495,7 @@ pub async fn storage_get_active_session(
/// Get user's access token from secure storage /// Get user's access token from secure storage
#[tauri::command] #[tauri::command]
#[specta::specta]
pub fn storage_get_access_token( pub fn storage_get_access_token(
creds: State<CredentialStoreWrapper>, creds: State<CredentialStoreWrapper>,
user_id: String, user_id: String,
@@ -491,6 +510,7 @@ pub fn storage_get_access_token(
/// Delete a user account and their token from secure storage /// Delete a user account and their token from secure storage
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_delete_user( pub async fn storage_delete_user(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
creds: State<'_, CredentialStoreWrapper>, creds: State<'_, CredentialStoreWrapper>,
@@ -523,7 +543,7 @@ pub async fn storage_delete_user(
} }
/// Playback progress info /// Playback progress info
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PlaybackProgress { pub struct PlaybackProgress {
pub item_id: String, pub item_id: String,
@@ -536,6 +556,7 @@ pub struct PlaybackProgress {
/// Update playback progress in local database /// Update playback progress in local database
/// This stores the progress locally for offline access and "continue watching" /// This stores the progress locally for offline access and "continue watching"
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_update_playback_progress( pub async fn storage_update_playback_progress(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -587,6 +608,7 @@ pub async fn storage_update_playback_progress(
/// Update playback progress with context in local database /// Update playback progress with context in local database
/// This stores the progress along with playback context (container vs single) /// This stores the progress along with playback context (container vs single)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_update_playback_context( pub async fn storage_update_playback_context(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -636,6 +658,7 @@ pub async fn storage_update_playback_context(
/// Mark item as played in local database /// Mark item as played in local database
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_mark_played( pub async fn storage_mark_played(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>, smart_cache: State<'_, SmartCacheWrapper>,
@@ -766,6 +789,7 @@ pub async fn storage_mark_played(
/// Get playback progress for an item /// Get playback progress for an item
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_playback_progress( pub async fn storage_get_playback_progress(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -798,6 +822,7 @@ pub async fn storage_get_playback_progress(
/// Mark pending sync as completed for an item /// Mark pending sync as completed for an item
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_mark_synced( pub async fn storage_mark_synced(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -822,6 +847,7 @@ pub async fn storage_mark_synced(
/// Toggle favorite status for an item in local database /// Toggle favorite status for an item in local database
/// This updates the is_favorite field and marks it for sync to Jellyfin /// This updates the is_favorite field and marks it for sync to Jellyfin
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_toggle_favorite( pub async fn storage_toggle_favorite(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -867,7 +893,7 @@ pub async fn storage_toggle_favorite(
// ============================================================================= // =============================================================================
/// Cached library info returned to frontend /// Cached library info returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CachedLibrary { pub struct CachedLibrary {
pub id: String, pub id: String,
@@ -878,7 +904,7 @@ pub struct CachedLibrary {
} }
/// Cached media item returned to frontend /// Cached media item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CachedItem { pub struct CachedItem {
pub id: String, pub id: String,
@@ -909,6 +935,7 @@ pub struct CachedItem {
/// Get cached libraries for a server /// Get cached libraries for a server
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_libraries( pub async fn storage_get_libraries(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
server_id: String, server_id: String,
@@ -971,6 +998,7 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
/// Get cached items with optional filtering /// Get cached items with optional filtering
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_items( pub async fn storage_get_items(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
server_id: String, server_id: String,
@@ -1037,6 +1065,7 @@ pub async fn storage_get_items(
/// Get a single cached item by ID /// Get a single cached item by ID
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_item( pub async fn storage_get_item(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
item_id: String, item_id: String,
@@ -1064,6 +1093,7 @@ pub async fn storage_get_item(
/// Search cached items using FTS /// Search cached items using FTS
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_search_items( pub async fn storage_search_items(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
server_id: String, server_id: String,
@@ -1105,6 +1135,7 @@ pub async fn storage_search_items(
/// Save a library to the cache /// Save a library to the cache
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_save_library( pub async fn storage_save_library(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
id: String, id: String,
@@ -1138,6 +1169,7 @@ pub async fn storage_save_library(
/// Save an item to the cache /// Save an item to the cache
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_save_item( pub async fn storage_save_item(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
item: CachedItem, item: CachedItem,
@@ -1201,6 +1233,7 @@ pub async fn storage_save_item(
/// Get count of pending sync operations for a user /// Get count of pending sync operations for a user
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn storage_get_pending_sync_count( pub async fn storage_get_pending_sync_count(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -1223,491 +1256,6 @@ pub async fn storage_get_pending_sync_count(
Ok(count) Ok(count)
} }
// =============================================================================
// Thumbnail Cache Commands
// =============================================================================
/// Get cached thumbnail path, returns None if not cached
/// Also updates last_accessed timestamp for LRU tracking
#[tauri::command]
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]
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]
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]
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]
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]
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(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]
pub async fn image_get_url(
repository_manager: State<'_, super::repository::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))
}
// =============================================================================
// People/Cast Cache Commands
// =============================================================================
/// Cached person info returned to frontend
#[derive(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(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]
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]
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]
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]
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)
}
// ===== Series Audio Preferences =====
/// Audio track preference for a series
#[derive(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]
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]
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)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+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))
}
+9 -1
View File
@@ -12,7 +12,7 @@ use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend /// Sync queue item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SyncQueueItem { pub struct SyncQueueItem {
pub id: i64, pub id: i64,
@@ -28,6 +28,7 @@ pub struct SyncQueueItem {
/// Queue a mutation for sync to server /// Queue a mutation for sync to server
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_queue_mutation( pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -59,6 +60,7 @@ pub async fn sync_queue_mutation(
/// Get all pending sync operations for a user /// Get all pending sync operations for a user
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_get_pending( pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -107,6 +109,7 @@ pub async fn sync_get_pending(
/// Mark a sync operation as in progress /// Mark a sync operation as in progress
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_mark_processing( pub async fn sync_mark_processing(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
id: i64, id: i64,
@@ -127,6 +130,7 @@ pub async fn sync_mark_processing(
/// Mark a sync operation as completed /// Mark a sync operation as completed
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_mark_completed( pub async fn sync_mark_completed(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
id: i64, id: i64,
@@ -147,6 +151,7 @@ pub async fn sync_mark_completed(
/// Mark a sync operation as failed with error message /// Mark a sync operation as failed with error message
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_mark_failed( pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
id: i64, id: i64,
@@ -173,6 +178,7 @@ pub async fn sync_mark_failed(
/// Get count of pending sync operations for a user /// Get count of pending sync operations for a user
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_get_pending_count( pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
@@ -195,6 +201,7 @@ pub async fn sync_get_pending_count(
/// Delete completed sync operations older than specified days /// Delete completed sync operations older than specified days
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_cleanup_completed( pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
days_old: i32, days_old: i32,
@@ -217,6 +224,7 @@ pub async fn sync_cleanup_completed(
/// Delete all sync operations for a user (used during logout) /// Delete all sync operations for a user (used during logout)
#[tauri::command] #[tauri::command]
#[specta::specta]
pub async fn sync_clear_user( pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
user_id: String, user_id: String,
+358 -232
View File
@@ -1,18 +1,26 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient; use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript) // Offline recovery probe interval.
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online // Reachability while online is driven by real repository traffic, so there is
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline // 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 /// Connectivity status
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ConnectivityStatus { pub struct ConnectivityStatus {
/// Whether the Jellyfin server is reachable /// Whether the Jellyfin server is reachable
@@ -39,216 +47,122 @@ impl Default for ConnectivityStatus {
} }
/// Connectivity change event emitted to frontend /// Connectivity change event emitted to frontend
#[derive(Debug, Clone, Serialize)] #[derive(specta::Type, Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ConnectivityChangeEvent { struct ConnectivityChangeEvent {
is_reachable: bool, is_reachable: bool,
} }
/// Connectivity monitor for tracking server reachability /// Shared reachability state and transition logic.
pub struct ConnectivityMonitor { ///
server_url: Arc<RwLock<Option<String>>>, /// This is the single place that mutates reachability and emits events. It is
http_client: Arc<HttpClient>, /// 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>>, 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>, app_handle: Option<AppHandle>,
} }
impl ConnectivityMonitor { impl ConnectivityReporter {
/// Create a new connectivity monitor fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
pub fn new(http_client: HttpClient) -> Self {
Self { Self {
server_url: Arc::new(RwLock::new(None)), status,
http_client: Arc::new(http_client), first_failure_at: Arc::new(RwLock::new(None)),
status: Arc::new(RwLock::new(ConnectivityStatus::default())), app_handle,
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
} }
} }
/// Set the Tauri app handle for event emission /// Current reachability as seen by this reporter (shared with the monitor
pub fn set_app_handle(&mut self, app_handle: AppHandle) { /// and the UI). Useful for callers that want to branch on connectivity.
self.app_handle = Some(app_handle); #[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 /// Test-only: force the reporter into the offline state without going through
pub async fn set_server_url(&self, url: String) { /// the debounce, so other modules' tests can set up an "offline" precondition.
log::info!("[ConnectivityMonitor] Setting server URL: {}", url); #[cfg(test)]
let mut server_url = self.server_url.write().await; pub async fn mark_unreachable_for_test(&self) {
*server_url = Some(url.clone()); self.apply_probe_result(false, Some("forced offline (test)".to_string()))
drop(server_url); .await;
// 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 /// Report that a real server request succeeded (or that the server answered
pub async fn get_status(&self) -> ConnectivityStatus { /// at all, e.g. with 401/404/5xx). The server is up — recover instantly.
self.status.read().await.clone() 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 /// Report that a real server request failed with a network-level error
pub async fn check_reachability(&self) -> bool { /// (connection refused, timeout, DNS). Subject to the time-window debounce:
// Mark as checking /// we only flip to offline once failures have persisted for
{ /// `OFFLINE_CONFIRM_WINDOW` with no intervening success.
let mut status = self.status.write().await; pub async fn report_network_failure(&self, error: Option<String>) {
status.is_checking = true; // If already offline, nothing to debounce.
if !self.status.read().await.is_server_reachable {
return;
} }
let server_url = self.server_url.read().await.clone(); let now = Instant::now();
let streak_start = {
if server_url.is_none() { let mut first = self.first_failure_at.write().await;
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured"); *first.get_or_insert(now)
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
}; };
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 /// Apply a deliberate reachability probe result (offline recovery probe or a
let is_reachable = self.http_client.ping(&ping_url).await; /// 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!( /// Core transition: update status and emit events only on an actual change.
"[ConnectivityMonitor] Ping result: {} (was: {})", async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
if is_reachable { "SUCCESS" } else { "FAILED" }, let was_reachable = {
if was_reachable { "reachable" } else { "unreachable" }
);
// Update status
{
let mut status = self.status.write().await; let mut status = self.status.write().await;
let was = status.is_server_reachable;
status.is_server_reachable = is_reachable; status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339()); status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable { status.connection_error = if is_reachable {
None None
} else { } else {
Some("Server unreachable".to_string()) Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
}; };
status.is_checking = false; status.is_checking = false;
} was
};
// Emit events if reachability changed
if is_reachable != was_reachable { if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await; self.emit_connectivity_change(is_reachable).await;
} if is_reachable {
self.emit_server_reconnected().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;
} }
}
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 /// Emit connectivity change event to frontend
@@ -275,87 +189,183 @@ impl ConnectivityMonitor {
} }
} }
/// Handle for the background monitoring task /// Connectivity monitor for tracking server reachability.
struct ConnectivityMonitorHandle { ///
/// 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>>>, server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>, http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>, reporter: ConnectivityReporter,
app_handle: Option<AppHandle>, is_monitoring: Arc<AtomicBool>,
} }
impl ConnectivityMonitorHandle { impl ConnectivityMonitor {
async fn check_reachability(&self) -> bool { /// 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(); 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; 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; let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {}",
if is_reachable { "SUCCESS" } else { "FAILED" }
);
// Update status self.reporter.apply_probe_result(is_reachable, None).await;
{
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;
}
is_reachable is_reachable
} }
async fn emit_connectivity_change(&self, is_reachable: bool) { /// Mark server as reachable (called after successful API call / login)
if let Some(app_handle) = &self.app_handle { pub async fn mark_reachable(&self) {
let event = ConnectivityChangeEvent { is_reachable }; self.reporter.report_success().await;
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
}
}
} }
async fn emit_server_reconnected(&self) { /// Mark server as unreachable directly.
if let Some(app_handle) = &self.app_handle { ///
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) { /// Used by deliberate signals (e.g. a failed login/connect) where the caller
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e); /// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_intervals() { fn test_intervals() {
// Verify intervals match TypeScript // Offline recovery probe interval (online has no polling).
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000); assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
} }
#[tokio::test] #[tokio::test]
@@ -367,4 +377,120 @@ mod tests {
assert!(status.connection_error.is_none()); assert!(status.connection_error.is_none());
assert!(!status.is_checking); 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);
}
} }
+1 -1
View File
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use crate::storage::db_service::{DatabaseService, Query, QueryParam}; use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Smart caching configuration /// Smart caching configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CacheConfig { pub struct CacheConfig {
/// Enable queue pre-caching /// Enable queue pre-caching
+1 -2
View File
@@ -77,7 +77,7 @@ impl DownloadManager {
} }
/// Information about a download /// Information about a download
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct DownloadInfo { pub struct DownloadInfo {
pub id: i64, pub id: i64,
@@ -120,7 +120,6 @@ pub struct DownloadTask {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::player::MediaType;
#[test] #[test]
fn test_download_manager_set_max_concurrent() { fn test_download_manager_set_max_concurrent() {
+135 -15
View File
@@ -284,22 +284,35 @@ impl JellyfinClient {
} }
/// Seek on a remote session /// 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( pub async fn session_seek(
&self, &self,
session_id: String, session_id: String,
position_ticks: i64, position_ticks: i64,
) -> Result<(), String> { ) -> Result<(), String> {
#[derive(serde::Serialize)] let url = format!(
#[serde(rename_all = "PascalCase")] "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
struct SeekRequest { self.config.server_url, session_id, position_ticks
seek_position_ticks: i64, );
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 { log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
seek_position_ticks: position_ticks, Ok(())
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
} }
/// Send a full GeneralCommand to a remote session. /// Send a full GeneralCommand to a remote session.
@@ -371,6 +384,82 @@ impl JellyfinClient {
let sessions = self.get_sessions().await?; let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id))) Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
} }
// --- JellyLMS multi-room sync groups -----------------------------------
//
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
// players ("zones") into synchronized multi-room sync groups. Players are
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
// stripping the `lms-` prefix off the session's device id (see
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
// with deviceId = "lms-{MacAddress}").
/// List current LMS sync groups.
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
self.get("/JellyLms/SyncGroups").await
}
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
pub async fn lms_create_sync_group(
&self,
master_mac: &str,
slave_macs: Vec<String>,
) -> Result<(), String> {
let payload = serde_json::json!({
"MasterMac": master_mac,
"SlaveMacs": slave_macs,
});
self.post("/JellyLms/SyncGroups", &payload).await
}
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
async fn delete(&self, endpoint: &str) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
.delete(&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));
}
Ok(())
}
}
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
///
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
/// follow it in lockstep.
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LmsSyncGroup {
#[serde(alias = "MasterMac")]
pub master_mac: String,
#[serde(default, alias = "MasterName")]
pub master_name: String,
#[serde(default, alias = "SlaveMacs")]
pub slave_macs: Vec<String>,
#[serde(default, alias = "SlaveNames")]
pub slave_names: Vec<String>,
} }
/// Default value for supports_remote_control when missing from API /// Default value for supports_remote_control when missing from API
@@ -381,72 +470,103 @@ fn default_true() -> bool {
} }
/// Session information from Jellyfin /// Session information from Jellyfin
#[derive(Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))] #[serde(rename_all = "camelCase")]
pub struct SessionInfo { pub struct SessionInfo {
#[serde(default)] #[serde(default)]
#[serde(alias = "Id")]
pub id: Option<String>, pub id: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "UserId")]
pub user_id: Option<String>, pub user_id: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "UserName")]
pub user_name: Option<String>, pub user_name: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "Client")]
pub client: Option<String>, pub client: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "DeviceName")]
pub device_name: Option<String>, pub device_name: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "DeviceId")]
pub device_id: Option<String>, pub device_id: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "ApplicationVersion")]
pub application_version: Option<String>, pub application_version: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "IsActive")]
pub is_active: Option<bool>, pub is_active: Option<bool>,
#[serde(default)] #[serde(default)]
#[serde(alias = "SupportsMediaControl")]
pub supports_media_control: Option<bool>, pub supports_media_control: Option<bool>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
#[serde(alias = "SupportsRemoteControl")]
pub supports_remote_control: bool, pub supports_remote_control: bool,
#[serde(default)] #[serde(default)]
#[serde(alias = "NowPlayingItem")]
pub now_playing_item: Option<NowPlayingItem>, pub now_playing_item: Option<NowPlayingItem>,
#[serde(default)] #[serde(default)]
#[serde(alias = "PlayState")]
pub play_state: Option<PlayState>, pub play_state: Option<PlayState>,
#[serde(default)] #[serde(default)]
#[serde(alias = "PlayableMediaTypes")]
pub playable_media_types: Option<Vec<String>>, pub playable_media_types: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
#[serde(alias = "SupportedCommands")]
pub supported_commands: Option<Vec<String>>, pub supported_commands: Option<Vec<String>>,
} }
#[derive(Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))] #[serde(rename_all = "camelCase")]
pub struct NowPlayingItem { pub struct NowPlayingItem {
#[serde(alias = "Id")]
pub id: Option<String>, pub id: Option<String>,
#[serde(alias = "Name")]
pub name: Option<String>, pub name: Option<String>,
#[serde(alias = "RunTimeTicks")]
pub run_time_ticks: Option<i64>, pub run_time_ticks: Option<i64>,
#[serde(alias = "Album")]
pub album: Option<String>, pub album: Option<String>,
#[serde(alias = "AlbumId")]
pub album_id: Option<String>, pub album_id: Option<String>,
#[serde(alias = "AlbumArtist")]
pub album_artist: Option<String>, pub album_artist: Option<String>,
#[serde(alias = "Artists")]
pub artists: Option<Vec<String>>, pub artists: Option<Vec<String>>,
#[serde(alias = "ImageTags")]
pub image_tags: Option<std::collections::HashMap<String, String>>, pub image_tags: Option<std::collections::HashMap<String, String>>,
#[serde(alias = "PrimaryImageTag")]
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
#[serde(alias = "AlbumPrimaryImageTag")]
pub album_primary_image_tag: Option<String>, pub album_primary_image_tag: Option<String>,
#[serde(rename = "Type")] #[serde(rename = "Type")]
pub item_type: Option<String>, pub item_type: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))] #[serde(rename_all = "camelCase")]
pub struct PlayState { pub struct PlayState {
#[serde(default)] #[serde(default)]
#[serde(alias = "PositionTicks")]
pub position_ticks: Option<i64>, pub position_ticks: Option<i64>,
#[serde(default)] #[serde(default)]
#[serde(alias = "CanSeek")]
pub can_seek: Option<bool>, pub can_seek: Option<bool>,
#[serde(default)] #[serde(default)]
#[serde(alias = "IsPaused")]
pub is_paused: Option<bool>, pub is_paused: Option<bool>,
#[serde(default)] #[serde(default)]
#[serde(alias = "IsMuted")]
pub is_muted: Option<bool>, pub is_muted: Option<bool>,
#[serde(default)] #[serde(default)]
#[serde(alias = "VolumeLevel")]
pub volume_level: Option<i32>, pub volume_level: Option<i32>,
#[serde(default)] #[serde(default)]
#[serde(alias = "RepeatMode")]
pub repeat_mode: Option<String>, pub repeat_mode: Option<String>,
#[serde(default)] #[serde(default)]
#[serde(alias = "ShuffleMode")]
pub shuffle_mode: Option<String>, pub shuffle_mode: Option<String>,
} }
+289 -230
View File
@@ -12,11 +12,12 @@ mod session_poller;
pub mod settings; pub mod settings;
mod storage; mod storage;
mod thumbnail; mod thumbnail;
mod utils; pub mod utils;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use log::{error, info}; use log::{error, info};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use log::warn; use log::warn;
@@ -27,7 +28,7 @@ use commands::{
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads, 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_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status, 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, pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download, offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status, player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
@@ -51,11 +52,13 @@ use commands::{
// Remote session control commands // Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute, remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands // Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper, sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands // Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status, playback_mode_get_remote_status,
// Playback reporting commands // Playback reporting commands
playback_reporter_init, playback_reporter_destroy, playback_reporter_init, playback_reporter_destroy,
@@ -95,11 +98,14 @@ use commands::{
repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_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_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_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url, repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_person, repository_get_items_by_person, repository_get_similar_items,
repository_get_subtitle_url, repository_get_video_download_url,
// Playlist commands // Playlist commands
playlist_create, playlist_delete, playlist_rename, playlist_get_items, playlist_create, playlist_delete, playlist_rename, playlist_get_items,
playlist_add_items, playlist_remove_items, playlist_move_item, playlist_add_items, playlist_remove_items, playlist_move_item,
@@ -346,6 +352,258 @@ 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,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
// 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_mode_set_transferring,
// 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_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
repository_report_playback_start,
repository_report_playback_progress,
repository_report_playback_stopped,
repository_get_image_url,
repository_mark_favorite,
repository_unmark_favorite,
repository_get_person,
repository_get_items_by_person,
repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
// Playlist commands
playlist_create,
playlist_delete,
playlist_rename,
playlist_get_items,
playlist_add_items,
playlist_remove_items,
playlist_move_item,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
convert_ticks_to_seconds,
calc_progress,
convert_percent_to_volume,
])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logger // Initialize logger
@@ -353,10 +611,22 @@ pub fn run() {
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.init(); .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() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::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 // Initialize database with proper app data directory
// Check for test mode environment variable first // Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -596,232 +866,21 @@ pub fn run() {
info!("[INIT] Application setup completed successfully"); info!("[INIT] Application setup completed successfully");
Ok(()) 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,
// 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,
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .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");
}
}
+155 -13
View File
@@ -12,7 +12,7 @@ use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext}; use crate::player::{PlayerController, QueueContext};
/// Playback mode - local device, remote session, or idle /// 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")] #[serde(tag = "type", rename_all = "lowercase")]
pub enum PlaybackMode { pub enum PlaybackMode {
Local, Local,
@@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle, 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 /// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager { pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -59,6 +81,19 @@ impl PlaybackModeManager {
self.is_transferring.load(Ordering::Relaxed) self.is_transferring.load(Ordering::Relaxed)
} }
/// Set the transferring flag directly.
///
/// The remote->local transfer is driven from the frontend in two steps
/// (`player_play_tracks` to start local playback, then
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
/// routing depends on this flag: while it's set, `player_play_tracks` plays
/// locally instead of casting back to the remote session. The frontend must
/// raise the flag *before* that first call and lower it when the sequence is
/// done (or aborts), so it can't be left stuck on.
pub fn set_transferring(&self, transferring: bool) {
self.is_transferring.store(transferring, Ordering::Relaxed);
}
/// Send volume command to remote session /// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown" /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback #[allow(dead_code)] // Called from Android JNI callback
@@ -159,7 +194,11 @@ impl PlaybackModeManager {
} }
/// Transfer playback from local device to remote Jellyfin session /// 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] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id); debug!("[PlaybackMode] session_id: {}", session_id);
log::info!( log::info!(
@@ -173,7 +212,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer // 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 // Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed); self.is_transferring.store(false, Ordering::Relaxed);
@@ -181,12 +220,24 @@ impl PlaybackModeManager {
result 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"); log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id); debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
// 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 // 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..."); log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock..."); debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await; let player = self.player_controller.lock().await;
@@ -195,7 +246,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0); let original_index = queue.current_index().unwrap_or(0);
let items = queue.items(); let items = queue.items();
@@ -210,7 +260,19 @@ impl PlaybackModeManager {
} }
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; 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(); let context = queue.context().clone();
log::info!( log::info!(
@@ -272,12 +334,48 @@ impl PlaybackModeManager {
} }
}; };
// Calculate position in ticks // Remote -> remote switch: take the current track and position from the
let start_position_ticks = if position_seconds > 0.5 { // session we're leaving, since the local player is idle and reports 0.
Some((position_seconds * 10_000_000.0) as i64) if let Some(ref prev_session_id) = previous_remote_session {
} else { log::info!(
None "[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) // Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context { match &queue_context {
@@ -392,6 +490,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string()); 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) // Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared"); log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{ {
@@ -577,6 +702,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 // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests { mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType}; use crate::player::{MediaItem, MediaSource, MediaType};
+6 -1
View File
@@ -678,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn // Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime // JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move { 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) => { Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback"); log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend // Emit PlaybackEnded event to frontend
+2 -2
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::repository::types::MediaItem; use crate::repository::types::MediaItem;
/// Autoplay decision result - determines what happens after playback ends /// 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")] #[serde(tag = "action", rename_all = "camelCase")]
pub enum AutoplayDecision { pub enum AutoplayDecision {
/// Stop playback (no next item or timer expired) /// Stop playback (no next item or timer expired)
@@ -21,7 +21,7 @@ pub enum AutoplayDecision {
} }
/// Autoplay settings (controls next episode behavior) /// Autoplay settings (controls next episode behavior)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AutoplaySettings { pub struct AutoplaySettings {
/// Whether autoplay is enabled for next episodes /// Whether autoplay is enabled for next episodes
+13 -10
View File
@@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode}; use super::{MediaSessionType, SleepTimerMode};
@@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions. /// state machine transitions.
/// ///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 /// 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")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent { pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback) /// Playback position updated (emitted periodically during playback)
@@ -113,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. /// Trait for emitting player events to the frontend.
/// ///
/// This abstraction allows backends to emit events without depending /// This abstraction allows backends to emit events without depending
@@ -141,7 +146,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter { impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) { 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); error!("Failed to emit player event: {}", e);
} }
} }
@@ -172,10 +179,6 @@ mod tests {
pub fn events(&self) -> Vec<PlayerStatusEvent> { pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone() self.events.lock_safe().clone()
} }
pub fn clear(&self) {
self.events.lock_safe().clear();
}
} }
impl PlayerEventEmitter for TestEventEmitter { impl PlayerEventEmitter for TestEventEmitter {
+7 -5
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
/// Context for the current queue - where did the queue items come from? /// Context for the current queue - where did the queue items come from?
/// This is used for remote playback transfer to send album/playlist context. /// 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")] #[serde(tag = "type", rename_all = "lowercase")]
pub enum QueueContext { pub enum QueueContext {
/// Playing from a specific album /// Playing from a specific album
@@ -23,7 +23,7 @@ pub enum QueueContext {
} }
/// Represents a subtitle track /// Represents a subtitle track
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SubtitleTrack { pub struct SubtitleTrack {
/// Stream index in the media source /// Stream index in the media source
pub index: i32, pub index: i32,
@@ -40,8 +40,9 @@ pub struct SubtitleTrack {
/// Represents a media item that can be played /// Represents a media item that can be played
/// ///
/// TRACES: UR-003, UR-004 | DR-002 /// 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")] #[serde(rename_all = "camelCase")]
#[specta(rename = "PlayerMediaItem")]
pub struct MediaItem { pub struct MediaItem {
/// Unique identifier /// Unique identifier
pub id: String, pub id: String,
@@ -106,7 +107,7 @@ pub struct MediaItem {
pub server_id: Option<String>, 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")] #[serde(rename_all = "lowercase")]
pub enum MediaType { pub enum MediaType {
Audio, Audio,
@@ -114,8 +115,9 @@ pub enum MediaType {
} }
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003 /// 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")] #[serde(tag = "type", rename_all = "lowercase")]
#[specta(rename = "PlayerMediaSource")]
pub enum MediaSource { pub enum MediaSource {
/// Streaming from Jellyfin server /// Streaming from Jellyfin server
Remote { Remote {

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