Compare commits

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

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

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

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

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

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

Unify into a single source of truth:

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

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

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

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

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

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

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

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

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

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

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

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

Tooling:
- Enable tauri-specta ErrorHandlingMode::Throw so commands.* return Promise<T>
  and throw (drop-in for invoke()).
- Disambiguate specta name collisions: player MediaItem/MediaSource ->
  PlayerMediaItem/PlayerMediaSource, auth ServerInfo -> AuthServerInfo.
- Regenerate bindings.ts; svelte-check passes (0 errors).
2026-06-20 19:27:29 +02:00
dtourolle 7d07b38d50 Exclude generated bindings.ts from Tailwind content scan
Tailwind v4 auto-scans all source files; the 3000-line generated bindings.ts has
no CSS classes and a large generated TS file can confuse class detection. Add an
@source not exclusion.
2026-06-20 18:38:59 +02:00
dtourolle dc1f516da7 Fix Android insta-crash: don't export TS bindings at runtime
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 18s
The tauri-specta `.export(...)` call ran inside run() on every debug app start
and tried to write `../src/lib/api/bindings.ts`, a path that doesn't exist on a
device — `.expect()` panicked at startup, crashing the app instantly on Android.
Bindings are generated by the `export_typescript_bindings` test instead.
2026-06-20 18:36:57 +02:00
dtourolle 731966246f Workstream E: generate bindings.ts via reusable specta_builder + export test
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 3m41s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 48s
- Extract specta_builder() so run() and a #[test] share one command list.
- Add export_typescript_bindings test that writes src/lib/api/bindings.ts.
- Configure the TS exporter bigint behavior to Number (matches existing frontend).
- Commit the generated bindings.ts (typed commands.* wrappers + all DTO types).
2026-06-20 18:25:54 +02:00
138 changed files with 7638 additions and 14143 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)
+79 -103
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,59 +125,40 @@ 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: Resolve Android NDK path
run: echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/25.1.8937393" >> "$GITHUB_ENV"
- name: Set app version from tag - name: Set app version from tag
run: | run: |
REF="${GITHUB_REF#refs/tags/v}" REF="${GITHUB_REF#refs/tags/v}"
@@ -201,8 +172,6 @@ jobs:
- name: Initialize Android project - name: Initialize Android project
run: bun run tauri android init run: bun run tauri android init
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
- name: Sync custom Android sources & gradle config - name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh run: ./scripts/sync-android-sources.sh
@@ -218,11 +187,7 @@ jobs:
EOF EOF
- name: Build signed Android APK - name: Build signed Android APK
run: bun run tauri android build --apk run: bun run tauri android build --apk true --target aarch64
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
ANDROID_SDK_ROOT: ${{ env.ANDROID_SDK_ROOT }}
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
- name: Collect & verify signed APK - name: Collect & verify signed APK
run: | run: |
@@ -247,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
@@ -273,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
@@ -307,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
@@ -322,7 +289,7 @@ 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: Publish Gitea release & upload assets - name: Publish Gitea release & upload assets
env: env:
@@ -346,11 +313,20 @@ jobs:
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}') '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
echo "📦 Creating release $VERSION on $REPO" echo "📦 Creating release $VERSION on $REPO"
RESP=$(curl -fsS -X POST "$API/repos/$REPO/releases" \ # -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$PAYLOAD") -d "$PAYLOAD")
RELEASE_ID=$(echo "$RESP" | jq -r '.id') if [ "$HTTP" = "201" ]; then
RELEASE_ID=$(jq -r '.id' resp.json)
elif [ "$HTTP" = "409" ]; then
echo "️ Release $VERSION already exists; fetching its id to upload assets"
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
-H "Authorization: token $TOKEN" | jq -r '.id')
else
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
fi
echo "Release id=$RELEASE_ID" echo "Release id=$RELEASE_ID"
for f in artifacts/android/* artifacts/linux/*; do for f in artifacts/android/* artifacts/linux/*; do
+15 -7
View File
@@ -95,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 ""
+8
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
@@ -50,3 +55,6 @@ logs
# Android signing keystore (NEVER commit) # Android signing keystore (NEVER commit)
android-keystore/ android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
+8 -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
@@ -68,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
+27 -477
View File
@@ -2,335 +2,25 @@
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 +30,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
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();
-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"
+8 -1
View File
@@ -2028,6 +2028,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rusqlite", "tokio-rusqlite",
"tokio-util", "tokio-util",
"urlencoding",
"uuid", "uuid",
] ]
@@ -4959,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"
@@ -5281,7 +5288,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
+1
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"
@@ -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
+1
View File
@@ -12,6 +12,7 @@ pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin /// Server information returned from Jellyfin
#[derive(specta::Type, 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,
+332 -46
View File
@@ -2,8 +2,9 @@
#[cfg(test)] #[cfg(test)]
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::State; use tauri::{Manager, State};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use crate::download::{DownloadInfo, DownloadManager}; use crate::download::{DownloadInfo, DownloadManager};
@@ -818,9 +819,7 @@ pub async fn start_download(
stream_url: String, stream_url: String,
target_dir: String, target_dir: String,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent; use crate::download::events::DownloadEvent;
use std::path::PathBuf;
use tauri::Emitter; use tauri::Emitter;
debug!("start_download called for download_id: {}", download_id); debug!("start_download called for download_id: {}", download_id);
@@ -906,16 +905,27 @@ pub async fn start_download(
} }
}; };
// Update status to downloading and save file_size if we got it // Update status to downloading and save file_size if we got it.
// Also persist the resolved stream URL + target dir so the queue pump can
// restart/resume this download by itself if needed.
let update_query = if let Some(size) = file_size_from_server { let update_query = if let Some(size) = file_size_from_server {
Query::with_params( Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?", "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)], vec![
QueryParam::Int64(size),
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
) )
} else { } else {
Query::with_params( Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
vec![QueryParam::Int64(download_id)], vec![
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
) )
}; };
@@ -936,29 +946,304 @@ pub async fn start_download(
// Build target path // Build target path
let target_path = PathBuf::from(&target_dir).join(&file_path); let target_path = PathBuf::from(&target_dir).join(&file_path);
// Create download task
let task = DownloadTask {
url: stream_url,
target_path: target_path.clone(),
};
// Start download in background
let app_clone = app.clone();
let item_id_clone = item_id.clone();
// Get a clone of the active downloads Arc for unregistering later // Get a clone of the active downloads Arc for unregistering later
let active_downloads = { let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?; let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads() manager.get_active_downloads()
}; };
// Run the worker in the background; on completion/failure it frees the slot
// and pumps the next pending download.
spawn_download_worker(
app.clone(),
download_id,
item_id,
stream_url,
target_path,
active_downloads,
);
Ok(())
}
/// Enqueue a download with its resolved stream URL, then let the queue pump
/// start it (or a higher-priority pending item) when a slot is free.
///
/// Unlike [`start_download`], this never errors when the concurrency limit is
/// reached: the URL is persisted on the row and the pump will pick it up once a
/// slot frees. This is the path bulk operations (album/series/season) use so
/// every queued item eventually downloads without the frontend re-issuing it.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_download(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
download_id: i64,
stream_url: String,
target_dir: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Persist the resolved URL/dir and mark the row pending so the pump can
// start it. We don't flip to 'downloading' here — the pump owns that.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir),
QueryParam::Int64(download_id),
],
);
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
// Kick the pump: it will start as many pending downloads as there are slots.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Enqueue a batch of already-queued video downloads, resolving each one's
/// transcode URL from the repository using the `quality_preset` stored on the
/// row. Then let the pump start them subject to the concurrency limit.
///
/// This is the bulk video path (series/season): `download_series`/
/// `download_season` insert the rows, then this resolves URLs and enqueues them
/// so they actually start. Resolving server-side avoids round-tripping every
/// episode URL through the frontend.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_video_downloads(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
app: tauri::AppHandle,
handle: String,
download_ids: Vec<i64>,
target_dir: String,
) -> Result<(), String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
for download_id in download_ids {
// Read the item + quality preset for this queued download.
let info_query = Query::with_params(
"SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
vec![QueryParam::Int64(download_id)],
);
let (item_id, quality): (String, String) = match db_service
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await
{
Ok(row) => row,
Err(e) => {
warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
continue;
}
};
// Build the transcode URL (pure URL builder, no server round-trip).
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
);
if let Err(e) = db_service.execute(update_query).await {
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
}
}
// Pump once: starts up to max_concurrent, the rest drain as slots free.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
/// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement.
async fn pump_download_queue(
app: tauri::AppHandle,
db_service: Arc<crate::storage::db_service::RusqliteService>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::events::DownloadEvent;
use tauri::Emitter;
let max_concurrent = {
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
Ok(m) => m,
Err(e) => {
error!("[pump] Failed to lock download manager: {}", e);
return;
}
};
manager.max_concurrent()
};
loop {
// How many slots are free right now?
let free_slots = {
let active = match active_downloads.lock() {
Ok(a) => a,
Err(e) => {
error!("[pump] Failed to lock active downloads: {}", e);
return;
}
};
max_concurrent.saturating_sub(active.len())
};
if free_slots == 0 {
return;
}
// Find the next pending, startable download (has a stream URL). Exclude
// anything already registered as active to avoid double-starting.
let next_query = Query::with_params(
"SELECT id, item_id, file_path, stream_url, target_dir
FROM downloads
WHERE status = 'pending'
AND stream_url IS NOT NULL
AND target_dir IS NOT NULL
ORDER BY priority DESC, queued_at ASC",
vec![],
);
let candidates: Vec<(i64, String, String, String, String)> = match db_service
.query_many(next_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
})
.await
{
Ok(rows) => rows,
Err(e) => {
error!("[pump] Failed to query pending downloads: {}", e);
return;
}
};
// Pick the first candidate not already active.
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
active_downloads
.lock()
.map(|active| !active.contains(id))
.unwrap_or(false)
});
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
Some(n) => n,
None => return, // Nothing pending to start
};
// Register the slot. If registration fails (race: another pump filled
// the last slot), stop — we'll be re-pumped when a slot frees.
{
let manager = app.state::<DownloadManagerWrapper>();
let manager = match manager.0.lock() {
Ok(m) => m,
Err(e) => {
error!("[pump] Failed to lock download manager: {}", e);
return;
}
};
if !manager.register_download(download_id) {
return;
}
info!(
"[pump] Download {} started. Active downloads: {}/{}",
download_id,
manager.active_count(),
manager.max_concurrent()
);
}
// Mark as downloading and stamp started_at.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(download_id)],
);
if let Err(e) = db_service.execute(update_query).await {
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
if let Ok(mut a) = active_downloads.lock() {
a.remove(&download_id);
}
continue;
}
// Emit started event so the UI flips the row.
let _ = app.emit(
"download-event",
DownloadEvent::Started {
download_id,
item_id: item_id.clone(),
},
);
let target_path = PathBuf::from(&target_dir).join(&file_path);
spawn_download_worker(
app.clone(),
download_id,
item_id,
stream_url,
target_path,
active_downloads.clone(),
);
}
}
/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
fn spawn_download_worker(
app: tauri::AppHandle,
download_id: i64,
item_id: String,
stream_url: String,
target_path: std::path::PathBuf,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use tauri::Emitter;
let task = DownloadTask {
url: stream_url,
target_path: target_path.clone(),
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
debug!("Download task started for download_id: {}", download_id); debug!("Download task started for download_id: {}", download_id);
let worker = DownloadWorker::new(); let worker = DownloadWorker::new();
// Progress callback that emits events to the frontend // Progress callback that emits events to the frontend
let progress_app = app_clone.clone(); let progress_app = app.clone();
let progress_item_id = item_id_clone.clone(); let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| { let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes let progress = total_bytes
.filter(|&t| t > 0) .filter(|&t| t > 0)
@@ -975,54 +1260,55 @@ pub async fn start_download(
let _ = progress_app.emit("download-event", event); let _ = progress_app.emit("download-event", event);
}; };
match worker.download(&task, on_progress).await { let result = worker.download(&task, on_progress).await;
Ok(result) => {
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
// Unregister from download manager // Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() { if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id); active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
} }
// Emit completed event - the frontend will handle state updates match result {
Ok(res) => {
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
let completed_event = DownloadEvent::Completed { let completed_event = DownloadEvent::Completed {
download_id, download_id,
item_id: item_id_clone, item_id,
file_path: target_path.to_string_lossy().to_string(), file_path: target_path.to_string_lossy().to_string(),
}; };
debug!("Emitting completed event: {:?}", completed_event); match app.emit("download-event", completed_event) {
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
match app_clone.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"), Ok(_) => debug!(" Completed event emitted successfully"),
Err(e) => error!(" Completed event emit failed: {:?}", e), Err(e) => error!(" Completed event emit failed: {:?}", e),
} }
} }
Err(e) => { Err(e) => {
error!("Download failed: {:?}", e); error!("Download failed: {:?}", e);
// Unregister from download manager
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len());
}
// Emit failed event - the frontend will handle state updates
let failed_event = DownloadEvent::Failed { let failed_event = DownloadEvent::Failed {
download_id, download_id,
item_id: item_id_clone.clone(), item_id,
error: e.to_string(), error: e.to_string(),
}; };
debug!("Emitting failed event: {:?}", failed_event); match app.emit("download-event", failed_event) {
match app_clone.emit("download-event", failed_event) {
Ok(_) => debug!(" Failed event emitted successfully"), Ok(_) => debug!(" Failed event emitted successfully"),
Err(e) => error!(" Failed event emit failed: {:?}", e), Err(e) => error!(" Failed event emit failed: {:?}", e),
} }
} }
} }
});
Ok(()) // A slot just freed — start the next pending download (if any).
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
return;
}
};
Arc::new(database.service())
};
pump_download_queue(app.clone(), db_service, active_downloads).await;
});
} }
/// Delete a completed download /// Delete a completed download
+5 -3
View File
@@ -41,12 +41,14 @@ pub fn playback_mode_is_transferring(
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
} }
/// Transfer playback from remote session back to local device /// Transfer playback from remote session back to local device
+117 -7
View File
@@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize, pub start_index: usize,
pub shuffle: bool, pub shuffle: bool,
pub context: PlayTracksContext, pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
} }
/// Context information for track playback /// Context information for track playback
@@ -411,9 +415,22 @@ pub async fn player_play_item(
} }
let controller = player.0.lock().await; let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
controller controller
.play_item(media_item) .play_item(media_item)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event // Emit queue changed event
controller.emit_queue_changed(); controller.emit_queue_changed();
@@ -1232,6 +1249,59 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
} }
} }
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index = adjusted_index
.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue /// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command] #[tauri::command]
@@ -1241,6 +1311,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayAlbumTrackRequest, request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@@ -1363,11 +1434,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play the queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller
.play_queue(media_items, start_index) // When controlling a remote session, cast the selection there instead of
.map_err(|e| e.to_string())?; // starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
} else {
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer // Set the queue context for remote transfer
{ {
@@ -1409,6 +1493,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayTracksRequest, request: PlayTracksRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@@ -1516,10 +1601,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.play_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?; // When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context // Set queue context
{ {
@@ -1783,6 +1891,7 @@ mod tests {
// Test finding track 1 (should be index 0) // Test finding track 1 (should be index 0)
let index1 = tracks.iter().position(|t| t.id == "track1"); let index1 = tracks.iter().position(|t| t.id == "track1");
assert_eq!(index1, Some(0), "Track 1 should be at index 0"); assert_eq!(index1, Some(0), "Track 1 should be at index 0");
assert_eq!(tracks[0].name, "Song 1", "Track at index 0 should carry its name");
// Test finding track 3 (should be index 2) // Test finding track 3 (should be index 2)
let index3 = tracks.iter().position(|t| t.id == "track3"); let index3 = tracks.iter().position(|t| t.id == "track3");
@@ -1856,6 +1965,7 @@ mod tests {
// Verify all tracks are in correct order // Verify all tracks are in correct order
assert_eq!(tracks[0].id, "id1"); assert_eq!(tracks[0].id, "id1");
assert_eq!(tracks[0].name, "Track 1", "Sorted track should retain its name");
assert_eq!(tracks[1].id, "id2"); assert_eq!(tracks[1].id, "id2");
assert_eq!(tracks[2].id, "id3"); assert_eq!(tracks[2].id, "id3");
assert_eq!(tracks[3].id, "id4"); assert_eq!(tracks[3].id, "id4");
+90 -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;
@@ -52,6 +53,7 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
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,
@@ -68,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
@@ -249,6 +260,21 @@ 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] #[specta::specta]
@@ -263,19 +289,73 @@ 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] #[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
@@ -499,16 +579,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]
+356 -230
View File
@@ -1,15 +1,23 @@
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(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
@@ -45,210 +53,116 @@ 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
View File
@@ -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() {
+56 -12
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.
@@ -382,71 +395,102 @@ fn default_true() -> bool {
/// Session information from Jellyfin /// Session information from Jellyfin
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")] #[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(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")] #[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(specta::Type, Debug, Clone, Deserialize, serde::Serialize)] #[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "PascalCase")] #[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>,
} }
+54 -17
View File
@@ -28,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,
@@ -96,11 +96,13 @@ 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_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,
@@ -347,14 +349,16 @@ fn create_player_backend(
} }
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] /// Construct the tauri-specta command builder. Shared by `run()` and the
pub fn run() { /// bindings-export test so the TypeScript bindings always match the handler.
// Initialize logger fn specta_builder() -> Builder<tauri::Wry> {
env_logger::Builder::from_default_env() Builder::<tauri::Wry>::new()
.filter_level(log::LevelFilter::Info) // Throw on error so generated `commands.*` return Promise<T> and throw,
.init(); // matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
let builder = Builder::<tauri::Wry>::new() .events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![ .commands(tauri_specta::collect_commands![
// Player commands // Player commands
player_play_item, player_play_item,
@@ -493,6 +497,8 @@ pub fn run() {
mark_download_completed, mark_download_completed,
mark_download_failed, mark_download_failed,
start_download, start_download,
enqueue_download,
enqueue_video_downloads,
get_download_manager_stats, get_download_manager_stats,
set_max_concurrent_downloads, set_max_concurrent_downloads,
get_smart_cache_stats, get_smart_cache_stats,
@@ -552,6 +558,7 @@ pub fn run() {
repository_get_next_up_episodes, repository_get_next_up_episodes,
repository_get_recently_played_audio, repository_get_recently_played_audio,
repository_get_resume_movies, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_get_genres,
repository_search, repository_search,
repository_get_playback_info, repository_get_playback_info,
@@ -566,6 +573,8 @@ pub fn run() {
repository_get_person, repository_get_person,
repository_get_items_by_person, repository_get_items_by_person,
repository_get_similar_items, repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
// Playlist commands // Playlist commands
playlist_create, playlist_create,
playlist_delete, playlist_delete,
@@ -580,18 +589,32 @@ pub fn run() {
convert_ticks_to_seconds, convert_ticks_to_seconds,
calc_progress, calc_progress,
convert_percent_to_volume, convert_percent_to_volume,
]); ])
}
// Export TypeScript bindings (types + typed invoke wrappers) in dev builds. #[cfg_attr(mobile, tauri::mobile_entry_point)]
#[cfg(debug_assertions)] pub fn run() {
builder // Initialize logger
.export(specta_typescript::Typescript::default(), "../src/lib/api/bindings.ts") env_logger::Builder::from_default_env()
.expect("Failed to export typescript bindings"); .filter_level(log::LevelFilter::Info)
.init();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default() 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") {
@@ -831,7 +854,21 @@ pub fn run() {
info!("[INIT] Application setup completed successfully"); info!("[INIT] Application setup completed successfully");
Ok(()) Ok(())
}) })
.invoke_handler(builder.invoke_handler())
.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");
}
}
+141 -12
View File
@@ -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>>>,
@@ -159,7 +181,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 +199,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 +207,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 +233,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 +247,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 +321,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 +477,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 +689,23 @@ mod tests {
); );
} }
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID // 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
+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 {
+2
View File
@@ -42,6 +42,7 @@ pub struct SubtitleTrack {
/// TRACES: UR-003, UR-004 | DR-002 /// TRACES: UR-003, UR-004 | DR-002
#[derive(specta::Type, 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,
@@ -116,6 +117,7 @@ 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(specta::Type, 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 {
+102 -1
View File
@@ -212,6 +212,23 @@ impl PlayerController {
Ok(()) Ok(())
} }
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue /// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it /// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> { pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -294,7 +311,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index /// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> { pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start // Reset autoplay counter on manual queue start
self.reset_autoplay_count(); self.reset_autoplay_count();
@@ -307,11 +344,37 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set) // Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() { if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?; self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
} }
Ok(()) Ok(())
} }
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback /// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> { pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play"); debug!("[PlayerController] play");
@@ -1316,6 +1379,44 @@ mod tests {
} }
} }
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test] #[test]
fn test_seek_to_zero() { fn test_seek_to_zero() {
let controller = PlayerController::default(); let controller = PlayerController::default();
+1 -1
View File
@@ -5,7 +5,7 @@
* beyond individual playback states. Enables persistent UI (miniplayer) and * beyond individual playback states. Enables persistent UI (miniplayer) and
* proper transitions between content types. * proper transitions between content types.
* *
* See software-architecture.md Section 2.1 for state machine diagram. * See docs/architecture/01-rust-backend.md for the state machine diagram.
*/ */
use log::info; use log::info;
+6 -1
View File
@@ -61,7 +61,12 @@ pub enum PlayerState {
} }
impl PlayerState { impl PlayerState {
/// Get the current playback position if available /// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> { pub fn position(&self) -> Option<f64> {
match self { match self {
PlayerState::Playing { position, .. } => Some(*position), PlayerState::Playing { position, .. } => Some(*position),
+183
View File
@@ -57,6 +57,81 @@ impl HybridRepository {
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
} }
/// Search only the local SQLite cache (downloaded content).
///
/// Fast (100ms timeout) — used to render instant results before the server
/// responds. Returns an empty result rather than erroring on timeout so the
/// caller can still fall through to the server.
pub async fn search_cache_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let query = query.to_string();
self.cache_with_timeout(async move { offline.search(&query, options).await })
.await
}
/// Search only the live Jellyfin server (full library).
pub async fn search_server_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
self.online.search(query, options).await
}
/// Merge cache and server search results into a single de-duplicated list.
///
/// Ordering: local (cached/downloaded) items first, then server-only items
/// appended. On a duplicate `id`, the server's item wins (fresher, more
/// complete metadata) but keeps the local item's earlier position.
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
use std::collections::HashMap;
// Index server items by id so we can (a) override duplicates with the
// server's metadata and (b) know which server items are brand new.
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
for item in server.items {
if !server_by_id.contains_key(&item.id) {
server_order.push(item.id.clone());
}
server_by_id.insert(item.id.clone(), item);
}
let mut items: Vec<MediaItem> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
// Local items first, in their original order. If the server also
// returned this item, take the server's copy (newer metadata).
for local in cache.items {
if !seen.insert(local.id.clone()) {
continue;
}
match server_by_id.remove(&local.id) {
Some(server_item) => items.push(server_item),
None => items.push(local),
}
}
// Then append server-only items, preserving the server's order.
for id in server_order {
if let Some(server_item) = server_by_id.remove(&id) {
if seen.insert(id) {
items.push(server_item);
}
}
}
let total_record_count = items.len();
SearchResult {
items,
total_record_count,
}
}
/// Cache-first query: try cache, fall back to server on miss. /// Cache-first query: try cache, fall back to server on miss.
/// ///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout) /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
@@ -274,6 +349,27 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await self.parallel_race(cache_future, server_future).await
} }
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let parent_id_owned = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_owned.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
});
let server_future = async move {
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
};
self.parallel_race(cache_future, server_future).await
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline); let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online); let online = Arc::clone(&self.online);
@@ -602,6 +698,14 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!() unimplemented!()
} }
@@ -759,6 +863,14 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!() unimplemented!()
} }
@@ -1058,4 +1170,75 @@ mod tests {
}; };
assert!(result_with_items.has_content(), "Result with items should have content"); assert!(result_with_items.has_content(), "Result with items should have content");
} }
#[test]
fn test_merge_search_local_first_then_server_appended() {
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("c", "Server C"),
create_test_item("d", "Server D"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
// Local items first (in order), then server-only items appended.
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
assert_eq!(merged.total_record_count, 4);
}
#[test]
fn test_merge_search_dedupes_with_server_winning() {
// "b" appears in both. Server metadata should win, but the item keeps
// its earlier (local) position and is not duplicated.
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("b", "Server B (fresher)"),
create_test_item("c", "Server C"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
assert_eq!(merged.total_record_count, 3);
}
#[test]
fn test_merge_search_handles_empty_sides() {
let only_server = HybridRepository::merge_search_results(
SearchResult { items: vec![], total_record_count: 0 },
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
);
assert_eq!(only_server.items.len(), 1);
assert_eq!(only_server.items[0].id, "x");
let only_cache = HybridRepository::merge_search_results(
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
SearchResult { items: vec![], total_record_count: 0 },
);
assert_eq!(only_cache.items.len(), 1);
assert_eq!(only_cache.items[0].id, "y");
}
} }
+9
View File
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>; ) -> Result<Vec<MediaItem>, RepoError>;
/// Get albums the user has played, but not recently ("rediscover" / haven't
/// listened to in a while). Returns albums sorted by least-recently played
/// first, optionally restricted to a parent library.
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies /// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>; async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
+38 -11
View File
@@ -778,6 +778,17 @@ impl MediaRepository for OfflineRepository {
Ok(items) Ok(items)
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// "Rediscover" is a discovery feature over the full server library.
// Offline only holds downloaded items, so there is nothing meaningful
// to surface here; the hybrid repo serves this from the server instead.
Ok(Vec::new())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> { async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12); let limit_val = limit.unwrap_or(12);
@@ -819,41 +830,57 @@ impl MediaRepository for OfflineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items // Derive genres from cached albums, tallying how many albums carry each
let mut genre_set = std::collections::HashSet::new(); // so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id { let (sql, params) = if let Some(pid) = parent_id {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL", "SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()), QueryParam::String(pid.to_string()),
] ],
) )
} else { } else {
( (
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL", "SELECT genres FROM items WHERE server_id = ? \
vec![QueryParam::String(self.server_id.clone())] AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
) )
}; };
let query = Query::with_params(sql, params); let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0)) let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.await .await
.map_err(|e| RepoError::Database { message: e })?; .map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows { for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) { if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec { for genre in genres_vec {
genre_set.insert(genre); if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
} }
} }
} }
let genres = genre_set let genres = counts
.into_iter() .into_iter()
.map(|name| Genre { id: name.clone(), name }) .map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect(); .collect();
Ok(genres) Ok(genres)
+333 -30
View File
@@ -7,6 +7,7 @@ use log::{debug, error, info};
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*}; use super::{MediaRepository, types::*};
@@ -16,6 +17,10 @@ pub struct OnlineRepository {
server_url: String, server_url: String,
user_id: String, user_id: String,
access_token: String, access_token: String,
/// Reports the outcome of every server request to the connectivity monitor.
/// This is the source of truth for the offline/online banner. `None` in
/// tests / contexts where connectivity tracking isn't wired up.
connectivity: Option<ConnectivityReporter>,
} }
impl OnlineRepository { impl OnlineRepository {
@@ -30,6 +35,44 @@ impl OnlineRepository {
server_url, server_url,
user_id, user_id,
access_token, access_token,
connectivity: None,
}
}
/// Attach a connectivity reporter so server outcomes drive the reachability
/// state observed by the UI. See `report_outcome`.
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
self.connectivity = Some(reporter);
self
}
/// Feed a request outcome into the connectivity monitor.
///
/// Classification (matches docs/architecture/07-connectivity.md):
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
/// so it is reachable → `report_success` (instant recovery).
/// - `Network` → connection-level failure → `report_network_failure`
/// (subject to the time-window debounce before going offline).
/// - `Database` → not a server signal → ignored.
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
let Some(reporter) = &self.connectivity else {
return;
};
match result {
Ok(_)
| Err(RepoError::Authentication { .. })
| Err(RepoError::NotFound { .. })
| Err(RepoError::Server { .. }) => {
reporter.report_success().await;
}
Err(RepoError::Network { message }) => {
reporter.report_network_failure(Some(message.clone())).await;
}
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
// Local-side errors (cache failure / already-offline) — not a
// statement about the server's reachability, so ignore them.
}
} }
} }
@@ -63,6 +106,12 @@ impl OnlineRepository {
/// Make authenticated GET request /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await;
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url) let request = self.http_client.client.get(&url)
@@ -110,6 +159,12 @@ impl OnlineRepository {
/// Make authenticated POST request /// Make authenticated POST request
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> { async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let result = self.post_json_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url) let request = self.http_client.client.post(&url)
@@ -145,6 +200,16 @@ impl OnlineRepository {
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
) -> Result<R, RepoError> {
let result = self.post_json_response_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> { ) -> Result<R, RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
@@ -193,9 +258,16 @@ impl OnlineRepository {
}) })
} }
/// Get a video stream URL for playback with optional seeking support. /// Get a video stream URL for playback at an arbitrary position (resume,
/// For direct streams, uses /Videos/{id}/stream.mp4 with transcoding to support seeking. /// transcoded seeking, audio-track switching).
/// For transcoded streams (HEVC/10-bit), includes StartTimeTicks parameter. ///
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
&self, &self,
item_id: &str, item_id: &str,
@@ -210,12 +282,11 @@ impl OnlineRepository {
// Use provided audio stream index, or default to 0 // Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string(); let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build URL with progressive MP4 transcoding for seeking support // Build an HLS transcode URL. VideoCodec lists h264 first so the server
// This works for both direct streams and HEVC content // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![ let mut params = vec![
("api_key", self.access_token.clone()), ("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()), ("DeviceId", "jellytau-tauri".to_string()),
("Container", "mp4".to_string()),
("VideoCodec", "h264".to_string()), ("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()), ("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index), ("AudioStreamIndex", audio_index),
@@ -223,6 +294,9 @@ impl OnlineRepository {
("VideoBitrate", "18000000".to_string()), ("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()), ("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()), ("TranscodingMaxAudioChannels", "2".to_string()),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
]; ];
if let Some(source_id) = media_source_id { if let Some(source_id) = media_source_id {
@@ -241,7 +315,7 @@ impl OnlineRepository {
.join("&"); .join("&");
let url = format!( let url = format!(
"{}/Videos/{}/stream.mp4?{}", "{}/Videos/{}/master.m3u8?{}",
self.server_url, item_id, query self.server_url, item_id, query
); );
@@ -494,10 +568,22 @@ impl MediaRepository for OnlineRepository {
if let Some(recursive) = opts.recursive { if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive)); endpoint.push_str(&format!("&Recursive={}", recursive));
} }
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (People only needed in get_item
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -685,6 +771,32 @@ impl MediaRepository for OnlineRepository {
Ok(final_result) Ok(final_result)
} }
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> { async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16); let limit_str = limit.unwrap_or(16);
let endpoint = format!( let endpoint = format!(
@@ -701,7 +813,12 @@ impl MediaRepository for OnlineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id); // Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid)); endpoint.push_str(&format!("&ParentId={}", pid));
@@ -718,17 +835,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre { struct JellyfinGenre {
id: String, id: String,
name: String, name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
} }
let response: GenresResponse = self.get_json(&endpoint).await?; let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response let genres: Vec<Genre> = response
.items .items
.into_iter() .into_iter()
.map(|g| Genre { .map(|g| Genre {
id: g.id, id: g.id,
name: g.name, name: g.name,
album_count: g.album_count.or(g.child_count),
}) })
.collect()) .collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
} }
async fn search( async fn search(
@@ -737,19 +878,30 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>, options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> { ) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50); let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!( let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true", "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id, query, limit self.user_id,
urlencoding::encode(query),
limit
); );
if let Some(opts) = options { if let Some(opts) = options {
if let Some(types) = opts.include_item_types { if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(","))); let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (plus Genres so cached items
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult { Ok(SearchResult {
@@ -859,7 +1011,18 @@ impl MediaRepository for OnlineRepository {
("h264,hevc".to_string(), "aac,mp3".to_string()) ("h264,hevc".to_string(), "aac,mp3".to_string())
}); });
#[cfg(not(target_os = "android"))] // Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = ( let (video_codecs, audio_codecs) = (
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(), "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(), "aac,mp3,opus,vorbis,flac".to_string(),
@@ -1136,23 +1299,29 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id); let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url) let result = async {
.header("X-Emby-Authorization", self.auth_header()) let request = self.http_client.client.delete(&url)
.build() .header("X-Emby-Authorization", self.auth_header())
.map_err(|e| RepoError::Network { .build()
message: format!("Failed to build request: {}", e), .map_err(|e| RepoError::Network {
})?; message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?; .map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(RepoError::Server { return Err(RepoError::Server {
message: format!("HTTP {}", response.status()), message: format!("HTTP {}", response.status()),
}); });
}
Ok(())
} }
.await;
Ok(()) self.report_outcome(&result).await;
result
} }
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> { async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
@@ -1377,6 +1546,91 @@ mod tests {
) )
} }
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
fn create_test_repository_with_connectivity(
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
.expect("Failed to create HTTP client for monitor");
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
let reporter = monitor.reporter();
let repo = create_test_repository().with_connectivity(reporter.clone());
(repo, reporter)
}
/// `report_outcome` is the seam between repository traffic and the
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
///
/// @req-test: UR-002 - Access media when online or offline
/// @req-test: DR-013 - Repository pattern for online/offline data access
#[tokio::test]
async fn test_report_outcome_classifies_server_answered_as_reachable() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a server that answers should be reported reachable"
);
}
// Ok should also report reachable.
reporter.mark_unreachable_for_test().await;
let ok: Result<(), RepoError> = Ok(());
repo.report_outcome(&ok).await;
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
}
/// Local-side errors must NOT flip reachability — they say nothing about the
/// server.
#[tokio::test]
async fn test_report_outcome_ignores_local_errors() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
!reporter.is_reachable().await,
"local-side error must not change reachability"
);
}
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
async fn test_report_outcome_network_error_is_debounced() {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a single network failure stays online (debounced)"
);
}
#[tokio::test] #[tokio::test]
async fn test_get_audio_stream_url_formats_correctly() { async fn test_get_audio_stream_url_formats_correctly() {
let repo = create_test_repository(); let repo = create_test_repository();
@@ -1392,6 +1646,46 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
"expected HLS master playlist, got: {url}"
);
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test] #[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() { async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository(); let repo = create_test_repository();
@@ -1552,4 +1846,13 @@ mod tests {
assert_eq!(response.items[0].id, "item1"); assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2"); assert_eq!(response.items[1].id, "item2");
} }
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
} }
+17 -4
View File
@@ -60,9 +60,11 @@ pub struct UserData {
/// Artist item with ID and name (for clickable artist links) /// Artist item with ID and name (for clickable artist links)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "PascalCase")] #[serde(rename_all = "camelCase")]
pub struct ArtistItem { pub struct ArtistItem {
#[serde(alias = "Id")]
pub id: String, pub id: String,
#[serde(alias = "Name")]
pub name: String, pub name: String,
} }
@@ -118,6 +120,7 @@ pub struct MediaItem {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub official_rating: Option<String>, pub official_rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "runTimeTicks")]
pub runtime_ticks: Option<i64>, pub runtime_ticks: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub primary_image_tag: Option<String>, pub primary_image_tag: Option<String>,
@@ -252,6 +255,10 @@ pub struct PlaybackInfo {
pub struct Genre { pub struct Genre {
pub id: String, pub id: String,
pub name: String, pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
} }
/// Image type /// Image type
@@ -402,15 +409,21 @@ mod tests {
#[test] #[test]
fn test_artist_item_serialize() { fn test_artist_item_serialize() {
// Test that ArtistItem serializes to PascalCase for consistency // ArtistItem serializes to camelCase for the frontend; it still accepts
// Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
let artist = ArtistItem { let artist = ArtistItem {
id: "test-id".to_string(), id: "test-id".to_string(),
name: "Test Artist".to_string(), name: "Test Artist".to_string(),
}; };
let json = serde_json::to_string(&artist).expect("Failed to serialize"); let json = serde_json::to_string(&artist).expect("Failed to serialize");
assert!(json.contains(r#""Id":"test-id""#)); assert!(json.contains(r#""id":"test-id""#));
assert!(json.contains(r#""Name":"Test Artist""#)); assert!(json.contains(r#""name":"Test Artist""#));
let from_pascal: ArtistItem =
serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
assert_eq!(from_pascal.id, "x");
assert_eq!(from_pascal.name, "Y");
} }
#[test] #[test]
+13
View File
@@ -21,6 +21,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("014_series_audio_preferences", MIGRATION_014), ("014_series_audio_preferences", MIGRATION_014),
("015_device_id", MIGRATION_015), ("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016), ("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017),
]; ];
/// Initial schema migration /// Initial schema migration
@@ -667,3 +668,15 @@ const MIGRATION_016: &str = r#"
ALTER TABLE user_player_settings ALTER TABLE user_player_settings
ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0; ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
"#; "#;
/// Migration to persist the resolved stream URL and target directory on each
/// download row. This lets the backend queue pump start a pending download by
/// itself (replaying the stored URL) once a concurrency slot frees up, instead
/// of relying on the frontend to re-issue every queued item.
const MIGRATION_017: &str = r#"
-- Resolved download source URL and on-disk target directory, captured when the
-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
-- URL simply won't be auto-started by the pump.
ALTER TABLE downloads ADD COLUMN stream_url TEXT;
ALTER TABLE downloads ADD COLUMN target_dir TEXT;
"#;
-1
View File
@@ -344,7 +344,6 @@ mod tests {
use super::*; use super::*;
use crate::storage::db_service::RusqliteService; use crate::storage::db_service::RusqliteService;
use rusqlite::Connection; use rusqlite::Connection;
use std::io::Write;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tempfile::TempDir; use tempfile::TempDir;
+5
View File
@@ -1,5 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
/* Exclude the generated tauri-specta bindings from Tailwind's content scan.
It contains no CSS classes (only TS types/command wrappers), and scanning a
large generated file can confuse Tailwind v4's automatic class detection. */
@source not "./lib/api/bindings.ts";
/* Custom theme variables for JellyTau */ /* Custom theme variables for JellyTau */
@theme { @theme {
--color-jellyfin: #00a4dc; --color-jellyfin: #00a4dc;
+23 -9
View File
@@ -10,6 +10,7 @@ import {
playNextEpisode, playNextEpisode,
type AutoplaySettings, type AutoplaySettings,
} from "./autoplay"; } from "./autoplay";
import type { PlayItemRequest } from "./bindings";
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: any) => { invoke: vi.fn(async (command: string, args?: any) => {
@@ -33,6 +34,23 @@ vi.mock("@tauri-apps/api/core", () => ({
}), }),
})); }));
// setAutoplaySettings sources the user id from the auth store
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: () => "user-1" },
}));
// Minimal valid PlayItemRequest for playNextEpisode tests
function makeItem(overrides: Partial<PlayItemRequest> = {}): PlayItemRequest {
return {
id: "item-123",
title: "Episode 1",
streamUrl: "http://example/stream",
videoCodec: "h264",
needsTranscoding: false,
...overrides,
};
}
describe("autoplay API", () => { describe("autoplay API", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -98,7 +116,7 @@ describe("autoplay API", () => {
(c) => c[0] === "player_set_autoplay_settings" (c) => c[0] === "player_set_autoplay_settings"
); );
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual({ settings }); expect(call![1]).toEqual({ userId: "user-1", settings });
}); });
it("should support different countdown values", async () => { it("should support different countdown values", async () => {
@@ -130,11 +148,7 @@ describe("autoplay API", () => {
describe("playNextEpisode", () => { describe("playNextEpisode", () => {
it("should play next episode with item", async () => { it("should play next episode with item", async () => {
const mockItem = { const mockItem = makeItem();
id: "item-123",
name: "Episode 1",
seriesId: "series-456",
};
await playNextEpisode(mockItem); await playNextEpisode(mockItem);
@@ -150,9 +164,9 @@ describe("autoplay API", () => {
it("should handle different item types", async () => { it("should handle different item types", async () => {
const items = [ const items = [
{ id: "1", name: "Episode 1" }, makeItem({ id: "1", title: "Episode 1" }),
{ id: "2", name: "Episode 2", seasonNumber: 1 }, makeItem({ id: "2", title: "Episode 2" }),
{ id: "3", name: "Episode 3", episodeNumber: 5 }, makeItem({ id: "3", title: "Episode 3" }),
]; ];
for (const item of items) { for (const item of items) {
+9 -11
View File
@@ -4,28 +4,26 @@
* Functions to control autoplay in the backend. * Functions to control autoplay in the backend.
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { PlayItemRequest, AutoplaySettings } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
export interface AutoplaySettings { export type { AutoplaySettings };
enabled: boolean;
countdownSeconds: number;
maxEpisodes: number;
}
export async function getAutoplaySettings(): Promise<AutoplaySettings> { export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return invoke("player_get_autoplay_settings"); return commands.playerGetAutoplaySettings();
} }
export async function setAutoplaySettings( export async function setAutoplaySettings(
settings: AutoplaySettings settings: AutoplaySettings
): Promise<AutoplaySettings> { ): Promise<AutoplaySettings> {
return invoke("player_set_autoplay_settings", { settings }); return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
} }
export async function cancelAutoplayCountdown(): Promise<void> { export async function cancelAutoplayCountdown(): Promise<void> {
return invoke("player_cancel_autoplay_countdown"); await commands.playerCancelAutoplayCountdown();
} }
export async function playNextEpisode(item: any): Promise<void> { export async function playNextEpisode(item: PlayItemRequest): Promise<void> {
return invoke("player_play_next_episode", { item }); await commands.playerPlayNextEpisode(item);
} }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
limit: 100, limit: 100,
}, },
requestId: 0,
}); });
}); });
}); });
+45 -158
View File
@@ -2,9 +2,8 @@
// All API calls go through Tauri commands in src-tauri/src/commands/repository.rs // All API calls go through Tauri commands in src-tauri/src/commands/repository.rs
// NO direct HTTP calls - everything routes through Rust backend // NO direct HTTP calls - everything routes through Rust backend
import { invoke } from "@tauri-apps/api/core"; import { commands } from "./bindings";
import type { QualityPreset } from "./quality-presets"; import type { QualityPreset } from "./quality-presets";
import { QUALITY_PRESETS } from "./quality-presets";
import type { import type {
Library, Library,
MediaItem, MediaItem,
@@ -39,12 +38,7 @@ export class RepositoryClient {
serverId: string serverId: string
): Promise<string> { ): Promise<string> {
console.log("[RepositoryClient] Creating Rust repository..."); console.log("[RepositoryClient] Creating Rust repository...");
this.handle = await invoke<string>("repository_create", { this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
serverUrl,
userId,
accessToken,
serverId,
});
// Store for URL construction // Store for URL construction
this._serverUrl = serverUrl; this._serverUrl = serverUrl;
@@ -60,7 +54,7 @@ export class RepositoryClient {
*/ */
async destroy(): Promise<void> { async destroy(): Promise<void> {
if (this.handle) { if (this.handle) {
await invoke("repository_destroy", { handle: this.handle }); await commands.repositoryDestroy(this.handle);
this.handle = null; this.handle = null;
this._serverUrl = null; this._serverUrl = null;
this._accessToken = null; this._accessToken = null;
@@ -84,119 +78,72 @@ export class RepositoryClient {
// ===== Library Methods (all via Rust) ===== // ===== Library Methods (all via Rust) =====
async getLibraries(): Promise<Library[]> { async getLibraries(): Promise<Library[]> {
return invoke<Library[]>("repository_get_libraries", { return commands.repositoryGetLibraries(this.ensureHandle());
handle: this.ensureHandle(),
});
} }
async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> { async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items", { return commands.repositoryGetItems(this.ensureHandle(), parentId, options ?? null);
handle: this.ensureHandle(),
parentId,
options: options ?? null,
});
} }
async getItem(itemId: string): Promise<MediaItem> { async getItem(itemId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_item", { return commands.repositoryGetItem(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> { async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_latest_items", { return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
handle: this.ensureHandle(),
parentId,
limit: limit ?? null,
});
} }
async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> { async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_items", { return commands.repositoryGetResumeItems(this.ensureHandle(), parentId ?? null, limit ?? null);
handle: this.ensureHandle(),
parentId: parentId ?? null,
limit: limit ?? null,
});
} }
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> { async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_next_up_episodes", { return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
handle: this.ensureHandle(),
seriesId: seriesId ?? null,
limit: limit ?? null,
});
} }
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> { async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_recently_played_audio", { return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
handle: this.ensureHandle(),
limit: limit ?? null,
});
} }
async getResumeMovies(limit?: number): Promise<MediaItem[]> { async getResumeMovies(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_movies", { return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
handle: this.ensureHandle(), }
limit: limit ?? null,
}); /** Albums the user has played but not listened to recently ("rediscover"). */
async getRediscoverAlbums(parentId?: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRediscoverAlbums(this.ensureHandle(), parentId ?? null, limit ?? null);
} }
async getGenres(parentId?: string): Promise<Genre[]> { async getGenres(parentId?: string): Promise<Genre[]> {
return invoke<Genre[]>("repository_get_genres", { return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
handle: this.ensureHandle(),
parentId: parentId ?? null,
});
} }
async search(query: string, options?: SearchOptions): Promise<SearchResult> { async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return invoke<SearchResult>("repository_search", { return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
handle: this.ensureHandle(),
query,
options: options ?? null,
});
} }
// ===== Playback Methods (all via Rust) ===== // ===== Playback Methods (all via Rust) =====
async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> { async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> {
return invoke<PlaybackInfo>("repository_get_playback_info", { return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_start", { await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_progress", { await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_stopped", { await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
// ===== Stream URL Methods (via Rust) ===== // ===== Stream URL Methods (via Rust) =====
async getAudioStreamUrl(itemId: string): Promise<string> { async getAudioStreamUrl(itemId: string): Promise<string> {
return invoke<string>("repository_get_audio_stream_url", { return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async getVideoStreamUrl( async getVideoStreamUrl(
@@ -205,13 +152,13 @@ export class RepositoryClient {
startTimeSeconds?: number, startTimeSeconds?: number,
audioStreamIndex?: number audioStreamIndex?: number
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_video_stream_url", { return commands.repositoryGetVideoStreamUrl(
handle: this.ensureHandle(), this.ensureHandle(),
itemId, itemId,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null,
startTimeSeconds: startTimeSeconds ?? null, startTimeSeconds ?? null,
audioStreamIndex: audioStreamIndex ?? null, audioStreamIndex ?? null
}); );
} }
// ===== URL Construction Methods (sync, no server call) ===== // ===== URL Construction Methods (sync, no server call) =====
@@ -221,12 +168,7 @@ export class RepositoryClient {
* The Rust backend constructs and returns the URL with proper credentials handling * The Rust backend constructs and returns the URL with proper credentials handling
*/ */
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> { async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
return invoke<string>("repository_get_image_url", { return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
handle: this.ensureHandle(),
itemId,
imageType,
options: options ?? null,
});
} }
/** /**
@@ -239,13 +181,7 @@ export class RepositoryClient {
streamIndex: number, streamIndex: number,
format: string = "vtt" format: string = "vtt"
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_subtitle_url", { return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format);
handle: this.ensureHandle(),
itemId,
mediaSourceId,
streamIndex,
format,
});
} }
/** /**
@@ -258,110 +194,61 @@ export class RepositoryClient {
quality: QualityPreset = "original", quality: QualityPreset = "original",
mediaSourceId?: string mediaSourceId?: string
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_video_download_url", { return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null);
handle: this.ensureHandle(),
itemId,
quality,
mediaSourceId: mediaSourceId ?? null,
});
} }
// ===== Favorite Methods (via Rust) ===== // ===== Favorite Methods (via Rust) =====
async markFavorite(itemId: string): Promise<void> { async markFavorite(itemId: string): Promise<void> {
return invoke("repository_mark_favorite", { await commands.repositoryMarkFavorite(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async unmarkFavorite(itemId: string): Promise<void> { async unmarkFavorite(itemId: string): Promise<void> {
return invoke("repository_unmark_favorite", { await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
// ===== Person Methods (via Rust) ===== // ===== Person Methods (via Rust) =====
async getPerson(personId: string): Promise<MediaItem> { async getPerson(personId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_person", { return commands.repositoryGetPerson(this.ensureHandle(), personId);
handle: this.ensureHandle(),
personId,
});
} }
async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> { async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items_by_person", { return commands.repositoryGetItemsByPerson(this.ensureHandle(), personId, options ?? null);
handle: this.ensureHandle(),
personId,
options: options ?? null,
});
} }
async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> { async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_similar_items", { return commands.repositoryGetSimilarItems(this.ensureHandle(), itemId, limit ?? null);
handle: this.ensureHandle(),
itemId,
limit: limit ?? null,
});
} }
// ===== Playlist Methods (via Rust) ===== // ===== Playlist Methods (via Rust) =====
async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> { async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> {
return invoke<PlaylistCreatedResult>("playlist_create", { return commands.playlistCreate(this.ensureHandle(), name, itemIds ?? null);
handle: this.ensureHandle(),
name,
itemIds: itemIds ?? null,
});
} }
async deletePlaylist(playlistId: string): Promise<void> { async deletePlaylist(playlistId: string): Promise<void> {
return invoke("playlist_delete", { await commands.playlistDelete(this.ensureHandle(), playlistId);
handle: this.ensureHandle(),
playlistId,
});
} }
async renamePlaylist(playlistId: string, name: string): Promise<void> { async renamePlaylist(playlistId: string, name: string): Promise<void> {
return invoke("playlist_rename", { await commands.playlistRename(this.ensureHandle(), playlistId, name);
handle: this.ensureHandle(),
playlistId,
name,
});
} }
async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> { async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> {
return invoke<PlaylistEntry[]>("playlist_get_items", { return commands.playlistGetItems(this.ensureHandle(), playlistId);
handle: this.ensureHandle(),
playlistId,
});
} }
async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> { async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> {
return invoke("playlist_add_items", { await commands.playlistAddItems(this.ensureHandle(), playlistId, itemIds);
handle: this.ensureHandle(),
playlistId,
itemIds,
});
} }
async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> { async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> {
return invoke("playlist_remove_items", { await commands.playlistRemoveItems(this.ensureHandle(), playlistId, entryIds);
handle: this.ensureHandle(),
playlistId,
entryIds,
});
} }
async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> { async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> {
return invoke("playlist_move_item", { await commands.playlistMoveItem(this.ensureHandle(), playlistId, itemId, newIndex);
handle: this.ensureHandle(),
playlistId,
itemId,
newIndex,
});
} }
// ===== Getters ===== // ===== Getters =====
+47 -236
View File
@@ -1,76 +1,42 @@
// Jellyfin API Types // Frontend type layer.
//
// Wire types are the single source of truth, generated from Rust by tauri-specta
// in ./bindings.ts. This module re-exports them under the names the frontend uses
// and adds frontend-only unions/helpers that have no backend equivalent.
export interface ServerInfo { export type {
id: string; ArtistItem,
name: string; AuthResult,
url: string; Genre,
} GetItemsOptions,
ImageOptions,
ImageType,
Library,
MediaItem,
MediaSource,
MediaStream,
Person,
PlaybackInfo,
PlaylistCreatedResult,
PlaylistEntry,
PlayState,
SearchOptions,
SearchResult,
ServerInfo,
User,
UserData,
} from "./bindings";
export interface User { import type { SessionInfo } from "./bindings";
id: string;
name: string;
serverId: string;
primaryImageTag?: string;
}
export interface AuthResult { // The frontend's "Session" is the backend remote-control session (SessionInfo).
user: User; // (Note: bindings also has a `Session` type — that's the auth/login session, a
accessToken: string; // different concept — so we alias explicitly here rather than re-export it.)
serverId: string; export type Session = SessionInfo;
}
export interface Library { // ---------------------------------------------------------------------------
id: string; // Frontend-only types (not part of the Rust command surface)
name: string; // ---------------------------------------------------------------------------
collectionType: LibraryType;
imageTag?: string;
}
export type LibraryType = "movies" | "tvshows" | "music" | "books" | "photos" | "homevideos" | "boxsets" | "playlists" | "channels" | "unknown";
export interface MediaItem {
id: string;
name: string;
type: ItemType;
serverId: string;
// Common fields
overview?: string;
productionYear?: number;
communityRating?: number;
officialRating?: string;
runTimeTicks?: number;
// Images
primaryImageTag?: string;
backdropImageTags?: string[];
parentBackdropImageTags?: string[];
// For audio
albumId?: string;
albumName?: string;
artists?: string[];
artistItems?: { id: string; name: string }[];
indexNumber?: number; // Track number
parentIndexNumber?: number; // Disc number
// For video
seriesId?: string;
seriesName?: string;
seasonId?: string;
seasonName?: string;
// Playback
userData?: UserData;
mediaStreams?: MediaStream[];
mediaSources?: MediaSource[];
// Cast & Crew
people?: Person[];
// Genres
genres?: string[];
}
export type ItemType = export type ItemType =
| "Movie" | "Movie"
@@ -87,7 +53,18 @@ export type ItemType =
| "ChannelFolderItem" | "ChannelFolderItem"
| "Person"; | "Person";
// Person/Cast types export type LibraryType =
| "movies"
| "tvshows"
| "music"
| "books"
| "photos"
| "homevideos"
| "boxsets"
| "playlists"
| "channels"
| "unknown";
export type PersonType = export type PersonType =
| "Actor" | "Actor"
| "Director" | "Director"
@@ -99,81 +76,6 @@ export type PersonType =
| "Conductor" | "Conductor"
| "Lyricist"; | "Lyricist";
export interface Person {
id: string;
name: string;
role?: string; // Character name for actors
type: PersonType;
primaryImageTag?: string;
}
export interface UserData {
playbackPositionTicks: number;
playCount: number;
isFavorite: boolean;
played: boolean;
lastPlayedDate?: string;
}
export interface MediaStream {
type: "Video" | "Audio" | "Subtitle";
codec?: string;
language?: string;
displayTitle?: string;
index: number;
isDefault: boolean;
isForced: boolean;
}
export interface MediaSource {
id: string;
name: string;
container?: string;
size?: number;
bitrate?: number;
supportsDirectPlay: boolean;
supportsDirectStream: boolean;
supportsTranscoding: boolean;
directStreamUrl?: string;
}
export interface PlaybackInfo {
mediaSourceId: string;
playSessionId: string;
streamUrl: string;
directPlay: boolean;
/** True if content requires transcoding (HEVC, 10-bit) - seeking requires server-side StartTimeTicks */
needsTranscoding: boolean;
}
// Remote session control types
export interface Session {
id: string;
userId: string;
userName: string;
client: string;
deviceName: string;
deviceId: string;
applicationVersion: string;
isActive: boolean;
supportsMediaControl: boolean;
supportsRemoteControl: boolean;
playState: PlayState | null;
nowPlayingItem: MediaItem | null;
playableMediaTypes: string[];
supportedCommands: string[];
}
export interface PlayState {
positionTicks: number;
canSeek: boolean;
isPaused: boolean;
isMuted: boolean;
volumeLevel: number;
repeatMode: string;
shuffleMode: string;
}
export type SessionCommand = export type SessionCommand =
| "PlayPause" | "PlayPause"
| "Stop" | "Stop"
@@ -183,94 +85,3 @@ export type SessionCommand =
| "PreviousTrack" | "PreviousTrack"
| "Mute" | "Mute"
| "Unmute"; | "Unmute";
export interface SearchResult {
items: MediaItem[];
totalRecordCount: number;
}
// Repository pattern for offline support
export interface MediaRepository {
// Libraries
getLibraries(): Promise<Library[]>;
// Items
getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult>;
getItem(itemId: string): Promise<MediaItem>;
getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]>;
// Home screen
getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]>;
getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]>;
getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]>;
getResumeMovies(limit?: number): Promise<MediaItem[]>;
// Genres
getGenres(parentId?: string): Promise<Genre[]>;
// Search
search(query: string, options?: SearchOptions): Promise<SearchResult>;
// Playback
getPlaybackInfo(itemId: string): Promise<PlaybackInfo>;
reportPlaybackStart(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void>;
reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void>;
// Images
getImageUrl(itemId: string, imageType: ImageType, options?: ImageOptions): string;
// Subtitles
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format?: string): string;
// Favorites
markFavorite(itemId: string): Promise<void>;
unmarkFavorite(itemId: string): Promise<void>;
// People/Cast
getPerson(personId: string): Promise<MediaItem>;
getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult>;
// Related Content
getSimilarItems(itemId: string, limit?: number): Promise<SearchResult>;
}
export interface Genre {
id: string;
name: string;
}
// Playlist types
export interface PlaylistEntry extends MediaItem {
playlistItemId: string;
}
export interface PlaylistCreatedResult {
id: string;
}
export interface GetItemsOptions {
startIndex?: number;
limit?: number;
sortBy?: string;
sortOrder?: "Ascending" | "Descending";
includeItemTypes?: ItemType[];
recursive?: boolean;
fields?: string[];
genres?: string[];
}
export interface SearchOptions {
limit?: number;
includeItemTypes?: ItemType[];
searchTerm?: string;
}
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo";
export interface ImageOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
tag?: string;
}
+8 -11
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { get } from "svelte/store"; import { get } from "svelte/store";
interface Props { interface Props {
itemId: string; itemId: string;
imageType?: string; imageType?: string;
tag?: string; tag?: string | null;
maxWidth?: number; maxWidth?: number;
maxHeight?: number; maxHeight?: number;
class?: string; class?: string;
@@ -47,15 +47,12 @@
const repositoryHandle = repository.getHandle(); const repositoryHandle = repository.getHandle();
// Call Rust to get image as base64 data URL // Call Rust to get image as base64 data URL
const dataUrl = await invoke<string>("image_get_url", { const dataUrl = await commands.imageGetUrl(repositoryHandle, {
repositoryHandle, itemId,
request: { imageType,
itemId, maxWidth,
imageType, maxHeight,
maxWidth, tag,
maxHeight,
tag,
},
}); });
// Use data URL directly // Use data URL directly
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -34,7 +34,7 @@
loading = true; loading = true;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
stats = await invoke<StorageStats>("get_download_storage_stats", { userId }); stats = await commands.getDownloadStorageStats(userId);
} }
} catch (error) { } catch (error) {
console.error("Failed to load storage stats:", error); console.error("Failed to load storage stats:", error);
@@ -56,7 +56,7 @@
deleting = true; deleting = true;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
await invoke("delete_all_downloads", { userId }); await commands.deleteAllDownloads(userId);
await downloads.refresh(userId); await downloads.refresh(userId);
await loadStats(); await loadStats();
} }
@@ -73,7 +73,7 @@
deletingAlbum = albumId; deletingAlbum = albumId;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
await invoke("delete_album_downloads", { albumId, userId }); await commands.deleteAlbumDownloads(albumId, userId);
await downloads.refresh(userId); await downloads.refresh(userId);
await loadStats(); await loadStats();
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
interface Props { interface Props {
@@ -89,21 +89,20 @@
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath); const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
// Get target directory for downloads // Get target directory for downloads
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
// Start each queued track download // Enqueue each track with its resolved stream URL. The backend queue
// pump starts up to max_concurrent at a time and advances through the
// rest automatically as slots free up — so we never hit (and silently
// drop) the concurrency limit the way startDownload did.
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) { for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try { try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id); const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) { if (streamUrl) {
await invoke("start_download", { await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
downloadId: downloadIds[i],
streamUrl,
targetDir,
});
} }
} catch (e) { } catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e); console.error(`Failed to enqueue download for track ${tracks[i].id}:`, e);
} }
} }
@@ -0,0 +1,142 @@
<!-- TRACES: UR-007 | DR-007 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
* Tapping or dragging a letter jumps to the first item that starts with it.
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
interface Props {
/** Uppercase letters (or "#") that currently have at least one item. */
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
function measure() {
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
function jumpTo(letter: string) {
if (!availableLetters.has(letter)) return;
activeLetter = letter;
onJump(letter);
}
// Allow dragging a finger/mouse down the strip to scrub through letters.
function letterAt(clientY: number): string | null {
const el = document.elementFromPoint(
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
}
let lastClientX = $state(0);
let isScrubbing = $state(false);
function handlePointerDown(e: PointerEvent) {
isScrubbing = true;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter) jumpTo(letter);
}
function handlePointerMove(e: PointerEvent) {
if (!isScrubbing) return;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter && letter !== activeLetter) jumpTo(letter);
}
function handlePointerUp() {
isScrubbing = false;
}
</script>
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={container}
class="flex flex-col items-center justify-between select-none touch-none py-1"
style="height: {stripHeight}px"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="navigation"
aria-label="Jump to letter"
>
{#each letters as letter (letter)}
{@const enabled = availableLetters.has(letter)}
<button
type="button"
data-letter={letter}
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
{letter}
</button>
{/each}
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { goto } from "$app/navigation";
import type { ArtistItem } from "$lib/api/types";
/**
* Renders a comma-separated list of artist names. When the artists carry
* ids (artistItems) each name links to that artist's detail page; otherwise
* it falls back to plain text from the names-only `artists` array.
*
* Consolidates the artist-linking markup used on the player and detail
* screens so every surface behaves the same way.
*/
interface Props {
artistItems?: ArtistItem[] | null;
artists?: string[] | null;
/** Tailwind classes for the link colour/state. */
linkClass?: string;
/** Tailwind classes for the non-linkable fallback text. */
textClass?: string;
/** Called before navigating (e.g. to close a full-screen player). */
onNavigate?: () => void;
}
let {
artistItems = null,
artists = null,
linkClass = "text-[var(--color-jellyfin)] hover:underline",
textClass = "text-gray-400",
onNavigate,
}: Props = $props();
const linkable = $derived(
(artistItems ?? []).filter((a) => a.id && a.id.trim() !== "")
);
function handleClick(artistId: string, e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onNavigate?.();
goto(`/library/${artistId}`);
}
</script>
{#if linkable.length > 0}
<span class="inline-flex flex-wrap items-baseline gap-1">
{#each linkable as artist, i (artist.id)}
<button onclick={(e) => handleClick(artist.id, e)} class={linkClass}>
{artist.name}
</button>{#if i < linkable.length - 1}<span class={textClass}>,</span>{/if}
{/each}
</span>
{:else if artists && artists.length > 0}
<span class={textClass}>{artists.join(", ")}</span>
{/if}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
import ArtistLinks from "./ArtistLinks.svelte";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("ArtistLinks", () => {
beforeEach(() => goto.mockClear());
it("renders a clickable link per artist that navigates to its detail page", async () => {
render(ArtistLinks, {
artistItems: [
{ id: "a1", name: "Radiohead" },
{ id: "a2", name: "Thom Yorke" },
],
});
const link = screen.getByRole("button", { name: "Radiohead" });
await fireEvent.click(link);
expect(goto).toHaveBeenCalledWith("/library/a1");
expect(screen.getByRole("button", { name: "Thom Yorke" })).toBeTruthy();
});
it("falls back to plain text when only names (no ids) are available", () => {
render(ArtistLinks, { artists: ["Unknown Artist"] });
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("Unknown Artist")).toBeTruthy();
});
it("ignores artistItems with blank ids and uses the names fallback", () => {
render(ArtistLinks, {
artistItems: [{ id: " ", name: "No Id Artist" }],
artists: ["No Id Artist"],
});
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("No Id Artist")).toBeTruthy();
});
it("calls onNavigate before navigating (e.g. to close a player)", async () => {
const onNavigate = vi.fn();
render(ArtistLinks, {
artistItems: [{ id: "a1", name: "Radiohead" }],
onNavigate,
});
await fireEvent.click(screen.getByRole("button", { name: "Radiohead" }));
expect(onNavigate).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith("/library/a1");
});
});
@@ -28,6 +28,7 @@
continue; continue;
} }
const type = person.type; const type = person.type;
if (!type) continue;
if (type in groups) { if (type in groups) {
groups[type].push(person); groups[type].push(person);
} else { } else {
@@ -84,7 +85,7 @@
<!-- Person image --> <!-- Person image -->
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2"> <div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
<CachedImage <CachedImage
itemId={person.id} itemId={person.id ?? ""}
imageType="Primary" imageType="Primary"
tag={person.primaryImageTag} tag={person.primaryImageTag}
maxWidth={200} maxWidth={200}
+1 -1
View File
@@ -55,7 +55,7 @@
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#each filteredPeople as person, index (person.id)} {#each filteredPeople as person, index (person.id)}
<button <button
onclick={(e) => handlePersonClick(person.id, e)} onclick={(e) => handlePersonClick(person.id ?? "", e)}
class="text-[var(--color-jellyfin)] hover:underline" class="text-[var(--color-jellyfin)] hover:underline"
> >
{person.name} {person.name}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import DownloadButtonCore from "./DownloadButtonCore.svelte"; import DownloadButtonCore from "./DownloadButtonCore.svelte";
import type { DownloadState } from "./DownloadButtonCore.svelte"; import type { DownloadState } from "./DownloadButtonCore.svelte";
@@ -80,18 +80,18 @@
} }
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
console.log(" Target directory:", targetDir); console.log(" Target directory:", targetDir);
// Queue and start download in single atomic operation // Queue and start download in single atomic operation
const downloadId = await invoke<number>("download_item_and_start", { const downloadId = await commands.downloadItemAndStart({
itemId, itemId,
userId, userId,
streamUrl, streamUrl,
targetDir, targetDir,
itemName: itemName || undefined, itemName: itemName || null,
artistName: artistName || undefined, artistName: artistName || null,
albumName: albumName || undefined, albumName: albumName || null,
}); });
console.log(" Download queued and started with ID:", downloadId); console.log(" Download queued and started with ID:", downloadId);
@@ -84,7 +84,7 @@
return null; return null;
}); });
function formatDuration(ticks?: number): string { function formatDuration(ticks?: number | null): string {
if (!ticks) return ""; if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ticks / 10000000);
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
@@ -100,7 +100,7 @@
if (!ep.userData || !ep.runTimeTicks) { if (!ep.userData || !ep.runTimeTicks) {
return 0; return 0;
} }
return (ep.userData.playbackPositionTicks / ep.runTimeTicks) * 100; return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
} }
function handlePlay() { function handlePlay() {
@@ -178,7 +178,7 @@
{episode.communityRating.toFixed(1)} {episode.communityRating.toFixed(1)}
</span> </span>
{/if} {/if}
{#if episode.userData?.played} {#if episode.userData?.isPlayed}
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]"> <span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
@@ -281,7 +281,7 @@
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if ep.userData?.played} {#if ep.userData?.isPlayed}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
+6 -6
View File
@@ -40,7 +40,7 @@
if (!episode.userData || !episode.runTimeTicks) { if (!episode.userData || !episode.runTimeTicks) {
return 0; return 0;
} }
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100; return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
}); });
const duration = $derived(formatDuration(episode.runTimeTicks)); const duration = $derived(formatDuration(episode.runTimeTicks));
@@ -137,7 +137,7 @@
{episode.name} {episode.name}
</h3> </h3>
<!-- Played indicator --> <!-- Played indicator -->
{#if episode.userData?.played} {#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg> </svg>
@@ -164,10 +164,10 @@
<VideoDownloadButton <VideoDownloadButton
itemId={episode.id} itemId={episode.id}
itemName={episode.name} itemName={episode.name}
seriesName={episode.seriesName} seriesName={episode.seriesName ?? undefined}
seasonName={episode.seasonName} seasonName={episode.seasonName ?? undefined}
episodeNumber={episode.indexNumber} episodeNumber={episode.indexNumber ?? undefined}
seasonNumber={episode.parentIndexNumber} seasonNumber={episode.parentIndexNumber ?? undefined}
size="sm" size="sm"
/> />
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte"; import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -125,7 +126,7 @@
selectedGenre = null; selectedGenre = null;
genreItems = []; genreItems = [];
} else { } else {
goto(config.backPath); navigateBack(config.backPath);
} }
} }
@@ -2,8 +2,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte"; import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte"; import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte"; import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -13,6 +16,8 @@
import type { MediaItem, Library, ItemType } from "$lib/api/types"; import type { MediaItem, Library, ItemType } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte"; import TrackList from "./TrackList.svelte";
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
import { excludePodcasts } from "$lib/utils/podcastFilter";
/** /**
* Generic media list page supporting Albums, Artists, Playlists, and Tracks * Generic media list page supporting Albums, Artists, Playlists, and Tracks
@@ -41,6 +46,7 @@
let items = $state<MediaItem[]>([]); let items = $state<MediaItem[]>([]);
let loading = $state(true); let loading = $state(true);
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state(""); let searchQuery = $state("");
let debouncedSearchQuery = $state(""); let debouncedSearchQuery = $state("");
let sortBy = $state<string>(""); let sortBy = $state<string>("");
@@ -74,12 +80,13 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
// Use backend search if search query is provided, otherwise use getItems with sort // Use backend search if search query is provided, otherwise use getItems with sort
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
if (debouncedSearchQuery.trim()) { if (debouncedSearchQuery.trim()) {
const result = await repo.search(debouncedSearchQuery, { const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType], includeItemTypes: [config.itemType],
limit: 10000, limit: 10000,
}); });
items = result.items; items = excludePodcasts(result.items);
} else { } else {
const result = await repo.getItems($currentLibrary.id, { const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType], includeItemTypes: [config.itemType],
@@ -88,7 +95,7 @@
recursive: true, recursive: true,
limit: 10000, limit: 10000,
}); });
items = result.items; items = excludePodcasts(result.items);
} }
} catch (e) { } catch (e) {
console.error(`Failed to load ${config.itemType}:`, e); console.error(`Failed to load ${config.itemType}:`, e);
@@ -124,7 +131,7 @@
} }
function goBack() { function goBack() {
goto(config.backPath); navigateBack(config.backPath);
} }
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`); const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
@@ -142,6 +149,54 @@
goto(`/library/${track.id}`); goto(`/library/${track.id}`);
} }
} }
// ===== A-Z jump bar =====
// Bucket a name to its index letter: A-Z, or "#" for digits/symbols/empty.
function letterFor(name: string): string {
const first = (name ?? "").trim().charAt(0).toUpperCase();
return first >= "A" && first <= "Z" ? first : "#";
}
// Only meaningful when the list is sorted alphabetically and long enough to scroll.
const isAlphaSorted = $derived(sortBy === "SortName");
const showAlphaBar = $derived(
isAlphaSorted &&
!loading &&
!debouncedSearchQuery.trim() &&
items.length > 30
);
const availableLetters = $derived.by(() => {
const set = new Set<string>();
if (showAlphaBar) {
for (const item of items) set.add(letterFor(item.name));
}
return set;
});
// First item index for each letter, honouring current ascending/descending order.
const firstIndexForLetter = $derived.by(() => {
const map = new Map<string, number>();
items.forEach((item, index) => {
const letter = letterFor(item.name);
if (!map.has(letter)) map.set(letter, index);
});
return map;
});
function jumpToLetter(letter: string) {
const index = firstIndexForLetter.get(letter);
if (index === undefined || !gridWrapper) return;
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived(
$shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem"
);
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
@@ -192,10 +247,19 @@
<p>No {config.title.toLowerCase()} found</p> <p>No {config.title.toLowerCase()} found</p>
</div> </div>
{:else} {:else}
{#if config.displayComponent === "grid"} <div class="flex gap-2">
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} /> <div bind:this={gridWrapper} class="flex-1 min-w-0">
{:else if config.displayComponent === "tracklist"} {#if config.displayComponent === "grid"}
<TrackList tracks={items} onTrackClick={handleTrackClick} /> <LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{/if} {:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
</div>
{/if}
</div>
{/if} {/if}
</div> </div>
@@ -69,12 +69,14 @@
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} /> <LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
{:else} {:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each items as item (item.id)} {#each items as item, index (item.id)}
<MediaCard <div data-grid-index={index}>
{item} <MediaCard
showProgress={true} {item}
onclick={() => onItemClick?.(item)} showProgress={true}
/> onclick={() => onItemClick?.(item)}
/>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -18,7 +18,7 @@
} }
function getImageTag(item: MediaItem | Library): string | undefined { function getImageTag(item: MediaItem | Library): string | undefined {
return "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined); return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
} }
function getSubtitle(item: MediaItem | Library): string { function getSubtitle(item: MediaItem | Library): string {
@@ -44,7 +44,7 @@
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
return 0; return 0;
} }
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100; return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
} }
function getTrackNumber(item: MediaItem | Library): string { function getTrackNumber(item: MediaItem | Library): string {
@@ -61,13 +61,14 @@
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""} {@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
{@const progress = getProgress(item)} {@const progress = getProgress(item)}
{@const trackNum = getTrackNumber(item)} {@const trackNum = getTrackNumber(item)}
{@const isPlayed = "userData" in item && item.userData?.played} {@const isPlayed = "userData" in item && item.userData?.isPlayed}
{@const downloadInfo = getDownloadInfo(item.id)} {@const downloadInfo = getDownloadInfo(item.id)}
{@const isDownloaded = downloadInfo?.status === "completed"} {@const isDownloaded = downloadInfo?.status === "completed"}
{@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"} {@const isDownloading = downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
<button <button
type="button" type="button"
data-grid-index={index}
onclick={() => onItemClick?.(item)} onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group" class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
> >
+2 -2
View File
@@ -52,7 +52,7 @@
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) { if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
return 0; return 0;
} }
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100; return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
}); });
const subtitle = $derived(() => { const subtitle = $derived(() => {
@@ -112,7 +112,7 @@
{/if} {/if}
<!-- Played indicator --> <!-- Played indicator -->
{#if "userData" in item && item.userData?.played} {#if "userData" in item && item.userData?.isPlayed}
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { MediaItem, PlaylistEntry } from "$lib/api/types"; import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast"; import { toast } from "$lib/stores/toast";
@@ -51,17 +51,14 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle, trackIds,
request: { startIndex: 0,
trackIds, shuffle: false,
startIndex: 0, context: {
shuffle: false, type: "playlist",
context: { playlistId: playlist.id,
type: "playlist", playlistName: playlist.name,
playlistId: playlist.id,
playlistName: playlist.name,
},
}, },
}); });
} catch (e) { } catch (e) {
@@ -76,17 +73,14 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle, trackIds,
request: { startIndex: 0,
trackIds, shuffle: true,
startIndex: 0, context: {
shuffle: true, type: "playlist",
context: { playlistId: playlist.id,
type: "playlist", playlistName: playlist.name,
playlistId: playlist.id,
playlistName: playlist.name,
},
}, },
}); });
} catch (e) { } catch (e) {
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads, videoDownloads } from "$lib/stores/downloads"; import { downloads, videoDownloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -53,7 +53,7 @@
console.log("📺 Starting season download for:", seasonName, "quality:", quality); console.log("📺 Starting season download for:", seasonName, "quality:", quality);
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
const basePath = `${targetDir}/videos`; const basePath = `${targetDir}/videos`;
// Queue all episodes in this season // Queue all episodes in this season
@@ -71,6 +71,13 @@
// Pin the season item // Pin the season item
await downloads.pinItem(seasonId); await downloads.pinItem(seasonId);
// Resolve each episode's transcode URL (server-side) and enqueue. The
// backend pump then starts up to max_concurrent and advances through the
// rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) { } catch (error) {
console.error("Failed to start season download:", error); console.error("Failed to start season download:", error);
} finally { } finally {
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads, videoDownloads } from "$lib/stores/downloads"; import { downloads, videoDownloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -47,7 +47,7 @@
console.log("📺 Starting series download for:", seriesName, "quality:", quality); console.log("📺 Starting series download for:", seriesName, "quality:", quality);
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
const basePath = `${targetDir}/videos`; const basePath = `${targetDir}/videos`;
// Queue all episodes // Queue all episodes
@@ -64,9 +64,12 @@
// Pin the series item // Pin the series item
await downloads.pinItem(seriesId); await downloads.pinItem(seriesId);
// Start downloads (the backend will handle queuing) // Resolve each episode's transcode URL (server-side) and enqueue. The
// For now, we'll rely on a download manager to pick them up // backend pump then starts up to max_concurrent and advances through the
// TODO: Implement batch download start // rest as slots free up.
const handle = auth.getRepository().getHandle();
await commands.enqueueVideoDownloads(handle, downloadIds, targetDir);
console.log(" Episodes enqueued; backend pump will start them");
} catch (error) { } catch (error) {
console.error("Failed to start series download:", error); console.error("Failed to start series download:", error);
} finally { } finally {
+16 -21
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue"; import { queue } from "$lib/stores/queue";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -62,14 +63,11 @@
if (context && context.type === "album") { if (context && context.type === "album") {
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`); console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
await invoke("player_play_album_track", { await commands.playerPlayAlbumTrack(repositoryHandle, {
repositoryHandle, albumId: context.albumId,
request: { albumName: context.albumName,
albumId: context.albumId, trackId: track.id,
albumName: context.albumName, shuffle: false,
trackId: track.id,
shuffle: false,
},
}); });
return; return;
} }
@@ -80,7 +78,7 @@
const trackIds = tracks.map((t) => t.id); const trackIds = tracks.map((t) => t.id);
// Determine context for queue // Determine context for queue
let playContext; let playContext: PlayTracksContext;
if (context?.type === "playlist") { if (context?.type === "playlist") {
playContext = { playContext = {
type: "playlist", type: "playlist",
@@ -88,17 +86,14 @@
playlistName: context.playlistName, playlistName: context.playlistName,
}; };
} else { } else {
playContext = { type: "custom" }; playContext = { type: "custom", label: null };
} }
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle, trackIds,
request: { startIndex: index,
trackIds, shuffle: false,
startIndex: index, context: playContext,
shuffle: false,
context: playContext,
},
}); });
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
@@ -145,7 +140,7 @@
goto(`/library/${artistId}`); goto(`/library/${artistId}`);
} }
function handleAlbumClick(albumId: string | undefined, e: Event) { function handleAlbumClick(albumId: string | null | undefined, e: Event) {
if (!albumId) return; if (!albumId) return;
e.stopPropagation(); e.stopPropagation();
goto(`/library/${albumId}`); goto(`/library/${albumId}`);
@@ -209,7 +204,7 @@
<!-- Track Rows --> <!-- Track Rows -->
<div class="space-y-1"> <div class="space-y-1">
{#each tracks as track, index (track.id)} {#each tracks as track, index (track.id)}
<div class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}"> <div data-grid-index={index} class="w-full group hover:bg-[var(--color-surface-hover)] rounded-lg transition-colors relative {currentlyPlayingId === track.id ? 'bg-[var(--color-jellyfin)]/10 border-l-4 border-[var(--color-jellyfin)]' : ''}">
<!-- Desktop View --> <!-- Desktop View -->
<button <button
onclick={() => handleTrackClick(track, index)} onclick={() => handleTrackClick(track, index)}
@@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(), setQueue: vi.fn(),
addToQueue: vi.fn(), addToQueue: vi.fn(),
}, },
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
})); }));
vi.mock("./DownloadButton.svelte", () => ({ vi.mock("./DownloadButton.svelte", () => ({
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -66,11 +66,11 @@
console.log("🎬 Starting video download for item:", itemId, "quality:", quality); console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
// Get stream URL based on quality // Get stream URL based on quality
const streamUrl = repo.getVideoDownloadUrl(itemId, quality); const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
console.log(" Stream URL obtained"); console.log(" Stream URL obtained");
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
// Create file path // Create file path
const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_"); const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_");
@@ -107,11 +107,7 @@
await downloads.pinItem(itemId); await downloads.pinItem(itemId);
// Actually start the download // Actually start the download
await invoke("start_download", { await commands.startDownload(downloadId, streamUrl, targetDir);
downloadId,
streamUrl,
targetDir,
});
console.log(" Download started"); console.log(" Download started");
} catch (error) { } catch (error) {
console.error("Failed to start video download:", error); console.error("Failed to start video download:", error);
+8 -8
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 --> <!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -74,28 +74,28 @@
async function handleSeekEnd() { async function handleSeekEnd() {
seeking = false; seeking = false;
seekPending = true; // Keep showing target position until backend catches up seekPending = true; // Keep showing target position until backend catches up
await invoke("player_seek", { position: seekValue }); await commands.playerSeek(seekValue);
} }
// Control handlers for Controls component // Control handlers for Controls component
async function handlePlayPause() { async function handlePlayPause() {
await invoke("player_toggle"); await commands.playerToggle();
} }
async function handlePrevious() { async function handlePrevious() {
await invoke("player_previous"); await commands.playerPrevious();
} }
async function handleNext() { async function handleNext() {
await invoke("player_next"); await commands.playerNext();
} }
async function handleToggleShuffle() { async function handleToggleShuffle() {
await invoke("player_toggle_shuffle"); await commands.playerToggleShuffle();
} }
async function handleCycleRepeat() { async function handleCycleRepeat() {
await invoke("player_cycle_repeat"); await commands.playerCycleRepeat();
} }
// Prefer album ID for artwork (all tracks in an album share the same cover) // Prefer album ID for artwork (all tracks in an album share the same cover)
@@ -128,7 +128,7 @@
async function handleQueueItemClick(index: number) { async function handleQueueItemClick(index: number) {
try { try {
queue.skipTo(index); queue.skipTo(index);
await invoke("player_skip_to", { index }); await commands.playerSkipTo(index);
} catch (e) { } catch (e) {
console.error("Failed to skip to queue item:", e); console.error("Failed to skip to queue item:", e);
} }
+7 -7
View File
@@ -14,7 +14,7 @@
* @req: UR-010 - Control playback of Jellyfin remote sessions * @req: UR-010 - Control playback of Jellyfin remote sessions
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -105,23 +105,23 @@
// Control handlers for Controls component // Control handlers for Controls component
async function handlePlayPause() { async function handlePlayPause() {
await invoke("player_toggle"); await commands.playerToggle();
} }
async function handlePrevious() { async function handlePrevious() {
await invoke("player_previous"); await commands.playerPrevious();
} }
async function handleNext() { async function handleNext() {
await invoke("player_next"); await commands.playerNext();
} }
async function handleToggleShuffle() { async function handleToggleShuffle() {
await invoke("player_toggle_shuffle"); await commands.playerToggleShuffle();
} }
async function handleCycleRepeat() { async function handleCycleRepeat() {
await invoke("player_cycle_repeat"); await commands.playerCycleRepeat();
} }
// Scrubbing (seek) handler // Scrubbing (seek) handler
@@ -133,7 +133,7 @@
const newPosition = percent * displayDuration; const newPosition = percent * displayDuration;
try { try {
await invoke("player_seek", { position: newPosition }); await commands.playerSeek(newPosition);
haptics.tap(); haptics.tap();
} catch (err) { } catch (err) {
console.error("Failed to seek:", err); console.error("Failed to seek:", err);
+4 -7
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action"; import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -35,7 +35,7 @@
let dragDisabled = $state(true); let dragDisabled = $state(true);
const flipDurationMs = 200; const flipDurationMs = 200;
function formatDuration(ticks?: number): string { function formatDuration(ticks?: number | null): string {
if (!ticks) return ""; if (!ticks) return "";
const seconds = Math.floor(ticks / 10000000); const seconds = Math.floor(ticks / 10000000);
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -79,10 +79,7 @@
queue.moveInQueue(fromIndex, toIndex); queue.moveInQueue(fromIndex, toIndex);
// Sync with backend // Sync with backend
await invoke("player_move_in_queue", { await commands.playerMoveInQueue(fromIndex, toIndex);
fromIndex,
toIndex,
});
} catch (e) { } catch (e) {
console.error("Failed to move queue item:", e); console.error("Failed to move queue item:", e);
// The store already updated optimistically, refresh if needed // The store already updated optimistically, refresh if needed
@@ -109,7 +106,7 @@
e.stopPropagation(); e.stopPropagation();
try { try {
queue.removeFromQueue(index); queue.removeFromQueue(index);
await invoke("player_remove_from_queue", { index }); await commands.playerRemoveFromQueue(index);
} catch (err) { } catch (err) {
console.error("Failed to remove from queue:", err); console.error("Failed to remove from queue:", err);
} }
+67 -56
View File
@@ -1,7 +1,7 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy, untrack } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js"; import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -20,9 +20,13 @@
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
onClose: () => void; onClose: () => void;
onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
onReportProgress?: (positionSeconds: number, isPaused: boolean) => void; // Reporting callbacks pass the played media's id explicitly so a late
onReportStart?: (positionSeconds: number) => void; // reportStop (fired from onDestroy during autoplay navigation) is attributed
onReportStop?: (positionSeconds: number) => void; // to the episode this player actually played, not the next episode whose URL
// is already active on the page.
onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
onReportStart?: (positionSeconds: number, reportId?: string) => void;
onReportStop?: (positionSeconds: number, reportId?: string) => void;
onEnded?: () => void; // Called when video playback ends naturally onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available hasNext?: boolean; // Whether there is a next episode available
@@ -30,9 +34,27 @@
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props(); let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
// navigation) is always attributed to the episode this player played.
// untrack() makes the intent explicit: capture the initial value only.
const reportMediaId = untrack(() => media?.id);
let videoElement: HTMLVideoElement | null = $state(null); let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false); let isPlaying = $state(false);
let currentTime = $state(0); let currentTime = $state(0);
// Guards against onEnded() firing more than once per loaded stream. For
// transcoded HLS, both the native `ended` event and the "fatal network error
// near end of stream" recovery path can fire for the same playback, which
// would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
// sleep-timer episode counter twice). Reset when the stream URL changes.
let endedFired = $state(false);
function notifyEnded() {
if (endedFired) return;
endedFired = true;
onEnded?.();
}
let isFullscreen = $state(false); let isFullscreen = $state(false);
let showControls = $state(true); let showControls = $state(true);
let showSleepTimerModal = $state(false); let showSleepTimerModal = $state(false);
@@ -140,13 +162,7 @@
const userId = auth.getUserId(); const userId = auth.getUserId();
if (!userId) return; if (!userId) return;
const preference = await invoke<{ seriesId: string, audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null, audioTrackIndex?: number | null } | null>( const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
"storage_get_series_audio_preference",
{
userId,
seriesId: media.seriesId
}
);
if (preference) { if (preference) {
console.log("[VideoPlayer] Loaded series audio preference:", preference); console.log("[VideoPlayer] Loaded series audio preference:", preference);
@@ -184,6 +200,7 @@
seekOffset = 0; seekOffset = 0;
isMediaReady = false; // Reset to loading state when stream URL changes isMediaReady = false; // Reset to loading state when stream URL changes
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
endedFired = false; // New stream loaded - allow onEnded to fire again
} }
}); });
@@ -281,9 +298,7 @@
if (isNearEnd) { if (isNearEnd) {
// Near end of stream - treat as natural end, don't restart // Near end of stream - treat as natural end, don't restart
console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended'); console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
if (onEnded) { notifyEnded();
onEnded();
}
} else if (hlsFatalRecoveryAttempts <= 3) { } else if (hlsFatalRecoveryAttempts <= 3) {
console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')'); console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
hls!.startLoad(); hls!.startLoad();
@@ -403,14 +418,12 @@
// Call Rust backend to start playback // Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5 // Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
// Send minimal video data - no complex serialization to avoid Tauri Android issues // Send minimal video data - no complex serialization to avoid Tauri Android issues
const response: any = await invoke("player_play_item", { const response: any = await commands.playerPlayItem({
item: { streamUrl: currentStreamUrl,
streamUrl: currentStreamUrl, title: media.name,
title: media.name, id: media.id,
id: media.id, videoCodec: needsTranscoding ? "hevc" : "h264",
videoCodec: needsTranscoding ? "hevc" : "h264", needsTranscoding: needsTranscoding,
needsTranscoding: needsTranscoding,
},
}); });
// Rust tells us which backend it's using // Rust tells us which backend it's using
@@ -422,7 +435,7 @@
if (useHtml5Element && !needsTranscoding) { if (useHtml5Element && !needsTranscoding) {
try { try {
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio"); console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
await invoke("player_stop"); await commands.playerStop();
didStopBackendEarly = true; // Track that we stopped the backend didStopBackendEarly = true; // Track that we stopped the backend
} catch (err) { } catch (err) {
console.warn("[VideoPlayer] Failed to stop backend player:", err); console.warn("[VideoPlayer] Failed to stop backend player:", err);
@@ -460,7 +473,7 @@
// For non-transcoded content, try to stop any backend player that might have started // For non-transcoded content, try to stop any backend player that might have started
if (!needsTranscoding) { if (!needsTranscoding) {
try { try {
await invoke("player_stop"); await commands.playerStop();
didStopBackendEarly = true; didStopBackendEarly = true;
} catch (stopErr) { } catch (stopErr) {
// Ignore errors when stopping // Ignore errors when stopping
@@ -478,7 +491,7 @@
// Report progress every 10 seconds while playing // Report progress every 10 seconds while playing
progressInterval = setInterval(() => { progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) { if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false); onReportProgress(currentTime, false, reportMediaId);
} }
}, 10000); }, 10000);
@@ -536,7 +549,7 @@
if (didStartNativePlayback && !didStopBackendEarly) { if (didStartNativePlayback && !didStopBackendEarly) {
try { try {
console.log("[VideoPlayer] Stopping backend player on component unmount"); console.log("[VideoPlayer] Stopping backend player on component unmount");
await invoke("player_stop"); await commands.playerStop();
} catch (err) { } catch (err) {
console.error("[VideoPlayer] Failed to stop backend player:", err); console.error("[VideoPlayer] Failed to stop backend player:", err);
} }
@@ -544,7 +557,7 @@
// Report stop when component is destroyed // Report stop when component is destroyed
if (onReportStop && currentTime > 0) { if (onReportStop && currentTime > 0) {
onReportStop(currentTime); onReportStop(currentTime, reportMediaId);
} }
}); });
@@ -738,7 +751,7 @@
startTimeUpdates(); // Start RAF loop for smooth time updates startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play // Report playback start on first play
if (!hasReportedStart && onReportStart) { if (!hasReportedStart && onReportStart) {
onReportStart(currentTime); onReportStart(currentTime, reportMediaId);
hasReportedStart = true; hasReportedStart = true;
} }
} }
@@ -748,7 +761,7 @@
stopTimeUpdates(); // Stop RAF loop when paused stopTimeUpdates(); // Stop RAF loop when paused
// Report progress when paused // Report progress when paused
if (onReportProgress) { if (onReportProgress) {
onReportProgress(currentTime, true); onReportProgress(currentTime, true, reportMediaId);
} }
} }
@@ -757,18 +770,16 @@
stopTimeUpdates(); // Stop RAF loop when ended stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends // Report stop when video ends
if (onReportStop) { if (onReportStop) {
onReportStop(currentTime); onReportStop(currentTime, reportMediaId);
} }
// Notify parent that video has ended (for next episode popup) // Notify parent that video has ended (for next episode popup)
if (onEnded) { notifyEnded();
onEnded();
}
} }
async function togglePlayPause() { async function togglePlayPause() {
if (!useHtml5Element) { if (!useHtml5Element) {
try { try {
const response = await invoke<{ state: string }>("player_toggle"); const response = (await commands.playerToggle()) as any;
isPlaying = response.state === "playing"; isPlaying = response.state === "playing";
} catch (err) { } catch (err) {
console.error("[VideoPlayer] Failed to toggle native player:", err); console.error("[VideoPlayer] Failed to toggle native player:", err);
@@ -807,13 +818,13 @@
} }
// Backend smart seeking handles both native and HTML5 // Backend smart seeking handles both native and HTML5
const response = await invoke<{strategy: string, position?: number, newUrl?: string, seekOffset?: number}>("player_seek_video", { const response = (await commands.playerSeekVideo(
repositoryHandle: repo.getHandle(), repo.getHandle(),
position: targetTime, targetTime,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null,
audioStreamIndex: selectedAudioTrackIndex ?? null, selectedAudioTrackIndex ?? null,
useHtml5: useHtml5Element, useHtml5Element
}); )) as any;
console.log("[VideoPlayer] Backend seek response:", response); console.log("[VideoPlayer] Backend seek response:", response);
@@ -1095,14 +1106,14 @@
if (!repo) throw new Error("Not authenticated"); if (!repo) throw new Error("Not authenticated");
// Call unified backend command // Call unified backend command
const response = await invoke<{strategy: string, success?: boolean, newUrl?: string, position?: number}>("player_switch_audio_track", { const response = (await commands.playerSwitchAudioTrack(
repositoryHandle: repo.getHandle(), repo.getHandle(),
streamIndex, streamIndex,
arrayIndex, arrayIndex,
useHtml5: useHtml5Element, useHtml5Element,
currentPosition: useHtml5Element && videoElement ? videoElement.currentTime + seekOffset : null, useHtml5Element && videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null
}); )) as any;
// Handle response based on strategy // Handle response based on strategy
if (response.strategy === "reloadStream" && useHtml5Element && videoElement) { if (response.strategy === "reloadStream" && useHtml5Element && videoElement) {
@@ -1171,14 +1182,14 @@
// Find the selected track info // Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex); const selectedTrack = audioTracks().find(t => t.index === streamIndex);
if (selectedTrack) { if (selectedTrack) {
await invoke("storage_save_series_audio_preference", { await commands.storageSaveSeriesAudioPreference(
userId, userId,
seriesId: media.seriesId, media.seriesId,
serverId: media.serverId, media.serverId ?? "",
audioTrackDisplayTitle: selectedTrack.displayTitle || null, selectedTrack.displayTitle || null,
audioTrackLanguage: selectedTrack.language || null, selectedTrack.language || null,
audioTrackIndex: streamIndex, streamIndex
}); );
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language); console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
} }
} catch (err) { } catch (err) {
@@ -1229,7 +1240,7 @@
// Use array index for ExoPlayer (0-based position in subtitle tracks array) // Use array index for ExoPlayer (0-based position in subtitle tracks array)
// If streamIndex is null (disable), pass null; otherwise use arrayIndex // If streamIndex is null (disable), pass null; otherwise use arrayIndex
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex); const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
await invoke("player_set_subtitle_track", { streamIndex: indexToUse }); await commands.playerSetSubtitleTrack(indexToUse);
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse); console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
} catch (error) { } catch (error) {
console.error("[VideoPlayer] Failed to set subtitle track:", error); console.error("[VideoPlayer] Failed to set subtitle track:", error);
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { volume, isMuted, mergedVolume } from "$lib/stores/player"; import { volume, isMuted, mergedVolume } from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode"; import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession, sessions } from "$lib/stores/sessions"; import { selectedSession, sessions } from "$lib/stores/sessions";
@@ -31,7 +31,7 @@
// Remote mode: send volume as 0-100 integer to remote session // Remote mode: send volume as 0-100 integer to remote session
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100)); await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
} else { } else {
await invoke("player_set_volume", { volume: newVolume }); await commands.playerSetVolume(newVolume);
} }
} }
@@ -39,7 +39,7 @@
if ($isRemoteMode && $selectedSession) { if ($isRemoteMode && $selectedSession) {
await sessions.sendToggleMute($selectedSession.id); await sessions.sendToggleMute($selectedSession.id);
} else { } else {
await invoke("player_toggle_mute"); await commands.playerToggleMute();
} }
} }
@@ -1,7 +1,7 @@
<!-- TRACES: UR-010 | DR-037 --> <!-- TRACES: UR-010 | DR-037 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import SessionPickerModal from "./SessionPickerModal.svelte"; import SessionPickerModal from "./SessionPickerModal.svelte";
@@ -42,18 +42,18 @@
// Set initial hint based on connection state // Set initial hint based on connection state
const hint = isConnected ? "cast_active" : "cast_discovery"; const hint = isConnected ? "cast_active" : "cast_discovery";
await invoke("sessions_set_polling_hint", { hint }); await commands.sessionsSetPollingHint(hint);
}); });
onDestroy(async () => { onDestroy(async () => {
// Reset to normal polling when component unmounts // Reset to normal polling when component unmounts
await invoke("sessions_set_polling_hint", { hint: "normal" }); await commands.sessionsSetPollingHint("normal");
}); });
// Update polling hint when connection state changes // Update polling hint when connection state changes
$effect(() => { $effect(() => {
const hint = isConnected ? "cast_active" : "cast_discovery"; const hint = isConnected ? "cast_active" : "cast_discovery";
invoke("sessions_set_polling_hint", { hint }); commands.sessionsSetPollingHint(hint);
}); });
const isConnected = $derived($selectedSession !== null); const isConnected = $derived($selectedSession !== null);
@@ -15,8 +15,8 @@
const nowPlaying = $derived(session.nowPlayingItem); const nowPlaying = $derived(session.nowPlayingItem);
const supportsSeek = $derived(playState?.canSeek ?? false); const supportsSeek = $derived(playState?.canSeek ?? false);
const supportsNextPrevious = $derived( const supportsNextPrevious = $derived(
session.supportedCommands.includes("NextTrack") && (session.supportedCommands ?? []).includes("NextTrack") &&
session.supportedCommands.includes("PreviousTrack") (session.supportedCommands ?? []).includes("PreviousTrack")
); );
async function handlePlayPause() { async function handlePlayPause() {
@@ -107,8 +107,8 @@
<h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4> <h4 class="text-base font-medium text-white truncate">{nowPlaying.name}</h4>
{#if nowPlaying.artists && nowPlaying.artists.length > 0} {#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p> <p class="text-sm text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName} {:else if nowPlaying?.album}
<p class="text-sm text-gray-400 truncate">{nowPlaying.albumName}</p> <p class="text-sm text-gray-400 truncate">{nowPlaying?.album}</p>
{/if} {/if}
</div> </div>
@@ -196,9 +196,9 @@
{#if playState.volumeLevel !== undefined} {#if playState.volumeLevel !== undefined}
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
{#if playState.isMuted || playState.volumeLevel === 0} {#if playState.isMuted || (playState.volumeLevel ?? 0) === 0}
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" /> <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
{:else if playState.volumeLevel < 50} {:else if (playState.volumeLevel ?? 0) < 50}
<path d="M7 9v6h4l5 5V4l-5 5H7z" /> <path d="M7 9v6h4l5 5V4l-5 5H7z" />
{:else} {:else}
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" /> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
@@ -51,11 +51,11 @@
<div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10"> <div class="flex items-center gap-3 mt-3 pt-3 border-t border-white/10">
<div class="w-12 h-12 rounded overflow-hidden flex-shrink-0"> <div class="w-12 h-12 rounded overflow-hidden flex-shrink-0">
<CachedImage <CachedImage
itemId={nowPlaying.id} itemId={nowPlaying.id ?? ""}
imageType="Primary" imageType="Primary"
tag={nowPlaying.primaryImageTag} tag={nowPlaying.primaryImageTag}
maxWidth={80} maxWidth={80}
alt={nowPlaying.name} alt={nowPlaying.name ?? undefined}
class="w-full h-full object-cover" class="w-full h-full object-cover"
/> />
</div> </div>
@@ -64,8 +64,8 @@
<p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p> <p class="text-sm font-medium text-white truncate">{nowPlaying.name}</p>
{#if nowPlaying.artists && nowPlaying.artists.length > 0} {#if nowPlaying.artists && nowPlaying.artists.length > 0}
<p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p> <p class="text-xs text-gray-400 truncate">{nowPlaying.artists.join(", ")}</p>
{:else if nowPlaying.albumName} {:else if nowPlaying.album}
<p class="text-xs text-gray-400 truncate">{nowPlaying.albumName}</p> <p class="text-xs text-gray-400 truncate">{nowPlaying.album}</p>
{/if} {/if}
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
@@ -1,7 +1,9 @@
<!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 --> <!-- TRACES: UR-010 | JA-021, JA-025 | DR-037 -->
<script lang="ts"> <script lang="ts">
import { get } from "svelte/store";
import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode"; import { playbackMode, isTransferring, transferError } from "$lib/stores/playbackMode";
import { playbackPosition } from "$lib/stores/player";
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
interface Props { interface Props {
@@ -14,8 +16,8 @@
async function handleSessionSelect(session: Session) { async function handleSessionSelect(session: Session) {
try { try {
// Transfer playback to remote session // Transfer playback to remote session, resuming at our current position
await playbackMode.transferToRemote(session.id); await playbackMode.transferToRemote(session.id, get(playbackPosition));
if (onSelectSession) { if (onSelectSession) {
onSelectSession(session); onSelectSession(session);
@@ -63,8 +65,8 @@
} }
} }
function getSessionIcon(client: string): string { function getSessionIcon(client: string | null | undefined): string {
const clientLower = client.toLowerCase(); const clientLower = (client ?? "").toLowerCase();
if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) { if (clientLower.includes("tv") || clientLower.includes("roku") || clientLower.includes("android tv")) {
return "tv"; return "tv";
} else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) { } else if (clientLower.includes("web") || clientLower.includes("chrome") || clientLower.includes("firefox")) {
@@ -68,7 +68,7 @@
<SessionCard <SessionCard
{session} {session}
selected={$sessions.selectedSessionId === session.id} selected={$sessions.selectedSessionId === session.id}
onclick={() => handleSessionClick(session.id)} onclick={() => handleSessionClick(session.id ?? "")}
/> />
{/each} {/each}
</div> </div>
+2 -2
View File
@@ -8,7 +8,7 @@
* TRACES: UR-009 | DR-011 * TRACES: UR-009 | DR-011
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
let cachedDeviceId: string | null = null; let cachedDeviceId: string | null = null;
@@ -30,7 +30,7 @@ export async function getDeviceId(): Promise<string> {
try { try {
// Rust backend handles generation and storage atomically // Rust backend handles generation and storage atomically
const deviceId = await invoke<string>("device_get_id"); const deviceId = await commands.deviceGetId();
cachedDeviceId = deviceId; cachedDeviceId = deviceId;
return deviceId; return deviceId;
} catch (e) { } catch (e) {
+3 -7
View File
@@ -1,7 +1,7 @@
// Favorites service - Handles toggling favorite status with optimistic updates // Favorites service - Handles toggling favorite status with optimistic updates
// TRACES: UR-017 | DR-021 // TRACES: UR-017 | DR-021
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
/** /**
@@ -29,11 +29,7 @@ export async function toggleFavorite(
const newIsFavorite = !currentIsFavorite; const newIsFavorite = !currentIsFavorite;
// 1. Update local database first (optimistic update) // 1. Update local database first (optimistic update)
await invoke("storage_toggle_favorite", { await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
userId,
itemId,
isFavorite: newIsFavorite,
});
// 2. Sync to Jellyfin server // 2. Sync to Jellyfin server
try { try {
@@ -45,7 +41,7 @@ export async function toggleFavorite(
} }
// 3. Mark as synced // 3. Mark as synced
await invoke("storage_mark_synced", { userId, itemId }); await commands.storageMarkSynced(userId, itemId);
} catch (error) { } catch (error) {
console.error("Failed to sync favorite to server:", error); console.error("Failed to sync favorite to server:", error);
// Favorite is stored locally and will be synced later // Favorite is stored locally and will be synced later
+7 -16
View File
@@ -1,8 +1,8 @@
// Image cache service - Handles lazy caching of thumbnails with LRU eviction // Image cache service - Handles lazy caching of thumbnails with LRU eviction
// TRACES: UR-007 | DR-016 // TRACES: UR-007 | DR-016
import { invoke } from "@tauri-apps/api/core";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
/** /**
* Statistics about the thumbnail cache * Statistics about the thumbnail cache
@@ -38,11 +38,7 @@ export async function getCachedImageUrl(
// Try to get cached version // Try to get cached version
try { try {
const cachedPath = await invoke<string | null>("thumbnail_get_cached", { const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag);
itemId,
imageType,
tag,
});
if (cachedPath) { if (cachedPath) {
// Convert file path to asset URL for Tauri // Convert file path to asset URL for Tauri
@@ -62,12 +58,7 @@ export async function getCachedImageUrl(
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`; const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
// Trigger background caching (fire and forget) // Trigger background caching (fire and forget)
invoke("thumbnail_save", { commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
itemId,
imageType,
tag,
url: serverImageUrl,
}).catch((e) => {
// Silently fail - caching is best-effort // Silently fail - caching is best-effort
console.debug("Background thumbnail cache failed:", e); console.debug("Background thumbnail cache failed:", e);
}); });
@@ -80,7 +71,7 @@ export async function getCachedImageUrl(
* Get thumbnail cache statistics * Get thumbnail cache statistics
*/ */
export async function getCacheStats(): Promise<ImageCacheStats> { export async function getCacheStats(): Promise<ImageCacheStats> {
return invoke("thumbnail_get_stats"); return commands.thumbnailGetStats();
} }
/** /**
@@ -89,14 +80,14 @@ export async function getCacheStats(): Promise<ImageCacheStats> {
* @param limitBytes - The maximum cache size in bytes (0 = unlimited) * @param limitBytes - The maximum cache size in bytes (0 = unlimited)
*/ */
export async function setCacheLimit(limitBytes: number): Promise<void> { export async function setCacheLimit(limitBytes: number): Promise<void> {
return invoke("thumbnail_set_limit", { limitBytes }); await commands.thumbnailSetLimit(limitBytes);
} }
/** /**
* Clear all cached thumbnails * Clear all cached thumbnails
*/ */
export async function clearCache(): Promise<void> { export async function clearCache(): Promise<void> {
return invoke("thumbnail_clear_cache"); await commands.thumbnailClearCache();
} }
/** /**
@@ -105,7 +96,7 @@ export async function clearCache(): Promise<void> {
* @param itemId - The Jellyfin item ID * @param itemId - The Jellyfin item ID
*/ */
export async function deleteItemCache(itemId: string): Promise<void> { export async function deleteItemCache(itemId: string): Promise<void> {
return invoke("thumbnail_delete_item", { itemId }); await commands.thumbnailDeleteItem(itemId);
} }
/** /**
+5 -1
View File
@@ -39,6 +39,10 @@ export async function cancelAutoPlay() {
/** /**
* Navigate to the next episode via goto(). * Navigate to the next episode via goto().
* Uses replaceState to prevent history buildup when auto-advancing. * Uses replaceState to prevent history buildup when auto-advancing.
*
* Advancing to a next episode always starts that episode from the
* beginning, even if it was previously started or watched. The `restart`
* query param signals the player page to skip the resume-progress check.
*/ */
function navigateToEpisode(episode: MediaItem) { function navigateToEpisode(episode: MediaItem) {
if (isNavigating) { if (isNavigating) {
@@ -48,7 +52,7 @@ function navigateToEpisode(episode: MediaItem) {
isNavigating = true; isNavigating = true;
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name); console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
nextEpisode.hidePopup(); nextEpisode.hidePopup();
goto(`/player/${episode.id}`, { replaceState: true }).finally(() => { goto(`/player/${episode.id}?restart=true`, { replaceState: true }).finally(() => {
isNavigating = false; isNavigating = false;
}); });
} }
+5 -19
View File
@@ -9,7 +9,7 @@
// //
// TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047 // TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
/** /**
@@ -42,13 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline) // Update local DB with context (always works, even offline)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_context", { await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId);
userId,
itemId,
positionTicks,
contextType,
contextId,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e); console.error("[PlaybackReporting] Failed to update playback context:", e);
} }
@@ -79,11 +73,7 @@ export async function reportPlaybackProgress(
// Update local DB only (progress updates are frequent, don't report to server) // Update local DB only (progress updates are frequent, don't report to server)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
userId,
itemId,
positionTicks,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -107,11 +97,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
// Update local DB first (always works, even offline) // Update local DB first (always works, even offline)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
userId,
itemId,
positionTicks,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -143,7 +129,7 @@ export async function markAsPlayed(itemId: string): Promise<void> {
// Update local DB first // Update local DB first
if (userId) { if (userId) {
try { try {
await invoke("storage_mark_played", { userId, itemId }); await commands.storageMarkPlayed(userId, itemId);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e); console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
} }
+24 -68
View File
@@ -8,9 +8,9 @@
* 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
*/ */
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player"; import { player, playbackPosition, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
import { sleepTimer } from "$lib/stores/sleepTimer"; import { sleepTimer } from "$lib/stores/sleepTimer";
@@ -20,33 +20,9 @@ import { preloadUpcomingTracks } from "$lib/services/preload";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { get } from "svelte/store"; import { get } from "svelte/store";
/** // PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
* Event types emitted by the player backend. // imported from $lib/api/bindings — they are the authoritative shapes emitted
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs // by src-tauri/src/player/events.rs.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
| { type: "position_update"; position: number; duration: number }
| { type: "state_changed"; state: string; media_id: string | null }
| { type: "media_loaded"; duration: number }
| { type: "playback_ended" }
| { type: "buffering"; percent: number }
| { type: "error"; message: string; recoverable: boolean }
| { type: "volume_changed"; volume: number; muted: boolean }
| { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number }
| { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean }
| { type: "countdown_tick"; remaining_seconds: number };
// Sleep timer mode type
export type SleepTimerMode =
| { kind: "off" }
| { kind: "time"; endTime: number }
| { kind: "endOfTrack" }
| { kind: "episodes"; remaining: number };
/** Event name for player status events from backend */
const PLAYER_EVENT_NAME = "player-event";
let unlistenFn: UnlistenFn | null = null; let unlistenFn: UnlistenFn | null = null;
let isInitialized = false; let isInitialized = false;
@@ -62,12 +38,9 @@ export async function initPlayerEvents(): Promise<void> {
} }
try { try {
unlistenFn = await listen<PlayerStatusEvent>( unlistenFn = await events.playerStatusEvent.listen((event) => {
PLAYER_EVENT_NAME, handlePlayerEvent(event.payload);
(event) => { });
handlePlayerEvent(event.payload);
}
);
isInitialized = true; isInitialized = true;
console.log("Player event listener initialized"); console.log("Player event listener initialized");
} catch (e) { } catch (e) {
@@ -152,6 +125,10 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
case "countdown_tick": case "countdown_tick":
handleCountdownTick(event.remaining_seconds); handleCountdownTick(event.remaining_seconds);
break; break;
// queue_changed is handled by the queue store's own listener
// ($lib/stores/queue), which is the single source of truth for
// shuffle/repeat/next/previous. No action needed here.
} }
} }
@@ -186,9 +163,16 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
} }
if (state === "playing" && currentItem) { if (state === "playing" && currentItem) {
// Use 0 for position/duration - will be updated by position_update events // Preserve the current position when the same track is already loaded
// (e.g. resuming from pause, or a spurious PlaybackRestart). Only reset
// to 0 when switching to a different track. position_update events keep
// it fresh either way, but resetting unconditionally caused the time to
// flash to 0:00 on pause/resume.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const startPosition = isSameTrack ? get(playbackPosition) : 0;
const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
player.setPlaying(currentItem, 0, initialDuration); player.setPlaying(currentItem, startPosition, initialDuration);
// Trigger preloading of upcoming tracks in the background // Trigger preloading of upcoming tracks in the background
preloadUpcomingTracks().catch((e) => { preloadUpcomingTracks().catch((e) => {
@@ -204,8 +188,6 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
player.setLoading(currentItem); player.setLoading(currentItem);
} }
// Update queue status on state change
await updateQueueStatus();
break; break;
case "idle": case "idle":
@@ -218,36 +200,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
playbackMode.setMode("idle"); playbackMode.setMode("idle");
} }
// Update queue status on state change
await updateQueueStatus();
break; break;
} }
} }
/**
* Update queue status from backend.
* Called on state changes instead of polling.
*/
async function updateQueueStatus(): Promise<void> {
try {
const queueStatus = await invoke<{
hasNext: boolean;
hasPrevious: boolean;
shuffle: boolean;
repeat: string;
}>("player_get_queue");
// Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
hasNext.set(queueStatus.hasNext);
hasPrevious.set(queueStatus.hasPrevious);
shuffle.set(queueStatus.shuffle);
repeat.set(queueStatus.repeat as "off" | "all" | "one");
} catch (e) {
console.error("[playerEvents] Failed to update queue status:", e);
}
}
/** /**
* Handle media loaded event. * Handle media loaded event.
*/ */
@@ -266,7 +222,7 @@ function handleMediaLoaded(duration: number): void {
async function handlePlaybackEnded(): Promise<void> { async function handlePlaybackEnded(): Promise<void> {
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.) // Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
try { try {
await invoke("player_on_playback_ended"); await commands.playerOnPlaybackEnded(null, null);
} catch (e) { } catch (e) {
console.error("[playerEvents] Failed to handle playback ended:", e); console.error("[playerEvents] Failed to handle playback ended:", e);
// Fallback: set idle state on error // Fallback: set idle state on error
@@ -284,7 +240,7 @@ async function handleError(message: string, recoverable: boolean): Promise<void>
// Stop backend player to prevent orphaned playback // Stop backend player to prevent orphaned playback
// This also reports playback stopped to Jellyfin server // This also reports playback stopped to Jellyfin server
try { try {
await invoke("player_stop"); await commands.playerStop();
console.log("Backend player stopped after error"); console.log("Backend player stopped after error");
} catch (e) { } catch (e) {
console.error("Failed to stop player after error:", e); console.error("Failed to stop player after error:", e);
+19 -5
View File
@@ -6,6 +6,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { preloadUpcomingTracks, updateCacheConfig, getCacheConfig } from "./preload"; import { preloadUpcomingTracks, updateCacheConfig, getCacheConfig } from "./preload";
import type { CacheConfig } from "$lib/api/bindings";
// updateCacheConfig now takes a full CacheConfig (matches the backend command)
function makeConfig(overrides: Partial<CacheConfig> = {}): CacheConfig {
return {
queuePrecacheEnabled: true,
queuePrecacheCount: 5,
albumAffinityEnabled: false,
albumAffinityThreshold: 0.75,
storageLimit: 2 * 1024 * 1024 * 1024,
wifiOnly: false,
...overrides,
};
}
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: any) => { invoke: vi.fn(async (command: string, args?: any) => {
@@ -135,10 +149,10 @@ describe("preload service", () => {
describe("updateCacheConfig", () => { describe("updateCacheConfig", () => {
it("should update cache config", async () => { it("should update cache config", async () => {
const config = { const config = makeConfig({
queuePrecacheEnabled: false, queuePrecacheEnabled: false,
queuePrecacheCount: 10, queuePrecacheCount: 10,
}; });
await expect(updateCacheConfig(config)).resolves.toBeUndefined(); await expect(updateCacheConfig(config)).resolves.toBeUndefined();
}); });
@@ -147,7 +161,7 @@ describe("preload service", () => {
const { invoke } = await import("@tauri-apps/api/core"); const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke); const invokeSpy = vi.mocked(invoke);
const config = { queuePrecacheEnabled: true }; const config = makeConfig({ queuePrecacheEnabled: true });
await updateCacheConfig(config); await updateCacheConfig(config);
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find(
@@ -157,8 +171,8 @@ describe("preload service", () => {
expect(call![1]).toHaveProperty("config", config); expect(call![1]).toHaveProperty("config", config);
}); });
it("should support partial config updates", async () => { it("should support overriding individual config options", async () => {
const config = { wifiOnly: true }; const config = makeConfig({ wifiOnly: true });
await expect(updateCacheConfig(config)).resolves.toBeUndefined(); await expect(updateCacheConfig(config)).resolves.toBeUndefined();
}); });
+8 -29
View File
@@ -5,15 +5,10 @@
* TRACES: UR-004, UR-011 | DR-006, DR-015 * TRACES: UR-004, UR-011 | DR-006, DR-015
*/ */
import { invoke } from '@tauri-apps/api/core'; import { commands } from '$lib/api/bindings';
import type { CacheConfig } from '$lib/api/bindings';
import { auth } from '$lib/stores/auth'; import { auth } from '$lib/stores/auth';
interface PreloadResult {
queuedCount: number;
alreadyDownloaded: number;
skipped: number;
}
interface PreloadOptions { interface PreloadOptions {
/** Enable debug logging */ /** Enable debug logging */
debug?: boolean; debug?: boolean;
@@ -39,10 +34,8 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
if (debug) console.log('[Preload] Triggering preload for user:', userId); if (debug) console.log('[Preload] Triggering preload for user:', userId);
const result = await invoke<PreloadResult>('player_preload_upcoming', { // downloadBasePath is currently unused in the backend
userId, const result = await commands.playerPreloadUpcoming(userId, '/downloads');
downloadBasePath: '/downloads' // This parameter is currently unused in the backend
});
if (debug) { if (debug) {
console.log('[Preload] Result:', { console.log('[Preload] Result:', {
@@ -66,27 +59,13 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
/** /**
* Update smart cache configuration * Update smart cache configuration
*/ */
export async function updateCacheConfig(config: { export async function updateCacheConfig(config: CacheConfig): Promise<void> {
queuePrecacheEnabled?: boolean; await commands.playerSetCacheConfig(config);
queuePrecacheCount?: number;
albumAffinityEnabled?: boolean;
albumAffinityThreshold?: number;
storageLimit?: number;
wifiOnly?: boolean;
}): Promise<void> {
await invoke('player_set_cache_config', { config });
} }
/** /**
* Get current cache configuration * Get current cache configuration
*/ */
export async function getCacheConfig(): Promise<{ export async function getCacheConfig(): Promise<CacheConfig> {
queuePrecacheEnabled: boolean; return await commands.playerGetCacheConfig();
queuePrecacheCount: number;
albumAffinityEnabled: boolean;
albumAffinityThreshold: number;
storageLimit: number;
wifiOnly: boolean;
}> {
return await invoke('player_get_cache_config');
} }
+11 -25
View File
@@ -6,7 +6,7 @@
// //
// TRACES: UR-002, UR-017, UR-025 | DR-014 // TRACES: UR-002, UR-017, UR-025 | DR-014
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
// Types matching Rust structs // Types matching Rust structs
@@ -73,12 +73,12 @@ class SyncService {
throw new Error("Not authenticated"); throw new Error("Not authenticated");
} }
const id = await invoke<number>("sync_queue_mutation", { const id = await commands.syncQueueMutation(
userId, userId,
operation, operation,
itemId, itemId,
payload: payload ? JSON.stringify(payload) : null, payload ? JSON.stringify(payload) : null
}); );
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`); console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
return id; return id;
@@ -90,11 +90,7 @@ class SyncService {
*/ */
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> { async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_toggle_favorite", { await commands.storageToggleFavorite(auth.getUserId() ?? "", itemId, isFavorite);
userId: auth.getUserId(),
itemId,
isFavorite,
});
return this.queueMutation( return this.queueMutation(
isFavorite ? "mark_favorite" : "unmark_favorite", isFavorite ? "mark_favorite" : "unmark_favorite",
@@ -111,11 +107,7 @@ class SyncService {
positionTicks: number positionTicks: number
): Promise<number> { ): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionTicks);
userId: auth.getUserId(),
itemId,
positionTicks,
});
return this.queueMutation("update_progress", itemId, { positionTicks }); return this.queueMutation("update_progress", itemId, { positionTicks });
} }
@@ -126,10 +118,7 @@ class SyncService {
*/ */
async queueMarkPlayed(itemId: string): Promise<number> { async queueMarkPlayed(itemId: string): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_mark_played", { await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
userId: auth.getUserId(),
itemId,
});
return this.queueMutation("mark_played", itemId); return this.queueMutation("mark_played", itemId);
} }
@@ -143,7 +132,7 @@ class SyncService {
return 0; return 0;
} }
return invoke<number>("sync_get_pending_count", { userId }); return commands.syncGetPendingCount(userId);
} }
/** /**
@@ -155,17 +144,14 @@ class SyncService {
return []; return [];
} }
return invoke<SyncQueueItem[]>("sync_get_pending", { return commands.syncGetPending(userId, limit ?? null);
userId,
limit,
});
} }
/** /**
* Clean up completed operations older than specified days * Clean up completed operations older than specified days
*/ */
async cleanup(daysOld: number = 7): Promise<number> { async cleanup(daysOld: number = 7): Promise<number> {
const deleted = await invoke<number>("sync_cleanup_completed", { daysOld }); const deleted = await commands.syncCleanupCompleted(daysOld);
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`); console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
return deleted; return deleted;
} }
@@ -204,7 +190,7 @@ class SyncService {
async clearUser(): Promise<void> { async clearUser(): Promise<void> {
const userId = auth.getUserId(); const userId = auth.getUserId();
if (userId) { if (userId) {
await invoke("sync_clear_user", { userId }); await commands.syncClearUser(userId);
console.log("[SyncService] Cleared sync queue for user"); console.log("[SyncService] Cleared sync queue for user");
} }
} }
+2 -4
View File
@@ -6,10 +6,8 @@ import { writable } from 'svelte/store';
export const isInitialized = writable(false); export const isInitialized = writable(false);
export const pendingSyncCount = writable(0); export const pendingSyncCount = writable(0);
export const isAndroid = writable(false); export const isAndroid = writable(false);
export const shuffle = writable(false); // Shuffle/repeat/next/previous state now lives in the event-driven queue store
export const repeat = writable<'off' | 'all' | 'one'>('off'); // ($lib/stores/queue), the single source of truth.
export const hasNext = writable(false);
export const hasPrevious = writable(false);
export const showSleepTimerModal = writable(false); export const showSleepTimerModal = writable(false);
// Library-specific state // Library-specific state
+56 -100
View File
@@ -6,10 +6,11 @@
// TRACES: UR-009, UR-012 | IR-009, IR-014 // TRACES: UR-009, UR-012 | IR-009, IR-014
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { RepositoryClient } from "$lib/api/repository-client"; import { RepositoryClient } from "$lib/api/repository-client";
import type { User, AuthResult } from "$lib/api/types"; import type { User, AuthResult } from "$lib/api/types";
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
import { connectivity } from "./connectivity"; import { connectivity } from "./connectivity";
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId"; import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
@@ -29,29 +30,6 @@ interface AuthState {
sessionVerified: boolean; sessionVerified: boolean;
} }
interface Session {
userId: string;
username: string;
serverId: string;
serverUrl: string;
serverName: string;
accessToken: string;
verified: boolean;
needsReauth: boolean;
}
interface ServerInfo {
name: string;
version: string;
id: string;
normalizedUrl: string;
}
interface SecurityStatus {
usingKeyring: boolean;
storageType: string;
}
function createAuthStore() { function createAuthStore() {
const initialState: AuthState = { const initialState: AuthState = {
isAuthenticated: false, isAuthenticated: false,
@@ -163,7 +141,7 @@ function createAuthStore() {
try { try {
// Check security status // Check security status
try { try {
const securityStatus = await invoke<SecurityStatus>("storage_get_security_status"); const securityStatus = await commands.storageGetSecurityStatus();
console.log("[Auth] Security status:", securityStatus); console.log("[Auth] Security status:", securityStatus);
if (!securityStatus.usingKeyring) { if (!securityStatus.usingKeyring) {
update((s) => ({ update((s) => ({
@@ -178,7 +156,7 @@ function createAuthStore() {
// Initialize auth manager and get session // Initialize auth manager and get session
console.log("[Auth] Initializing auth manager..."); console.log("[Auth] Initializing auth manager...");
const session = await invoke<Session | null>("auth_initialize"); const session = await commands.authInitialize();
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found"); console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
if (session) { if (session) {
@@ -192,12 +170,12 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
try { try {
console.log("[Auth] Configuring Rust player with restored session..."); console.log("[Auth] Configuring Rust player with restored session...");
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl: session.serverUrl, session.serverUrl,
accessToken: session.accessToken, session.accessToken,
userId: session.userId, session.userId,
deviceId: deviceId, deviceId
}); );
console.log("[Auth] Rust player configured for automatic playback reporting"); console.log("[Auth] Rust player configured for automatic playback reporting");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to configure Rust player:", error); console.error("[Auth] Failed to configure Rust player:", error);
@@ -231,7 +209,7 @@ function createAuthStore() {
// Start background session verification // Start background session verification
try { try {
const verifyDeviceId = await getDeviceId(); const verifyDeviceId = await getDeviceId();
await invoke("auth_start_verification", { deviceId: verifyDeviceId }); await commands.authStartVerification(verifyDeviceId);
console.log("[Auth] Background verification started"); console.log("[Auth] Background verification started");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to start verification:", error); console.error("[Auth] Failed to start verification:", error);
@@ -273,7 +251,7 @@ function createAuthStore() {
try { try {
console.log("[Auth] Connecting to server:", serverUrl); console.log("[Auth] Connecting to server:", serverUrl);
const serverInfo = await invoke<ServerInfo>("auth_connect_to_server", { serverUrl }); const serverInfo = await commands.authConnectToServer(serverUrl);
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version); console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl); console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
@@ -302,47 +280,32 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Logging in as:", username); console.log("[Auth] Logging in as:", username);
const authResult = await invoke<AuthResult>("auth_login", { const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
serverUrl,
username,
password,
deviceId,
});
console.log("[Auth] Login successful:", authResult.user); console.log("[Auth] Login successful:", authResult.user);
// Save to storage // Save to storage
await invoke("storage_save_server", { await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
id: authResult.serverId,
name: serverName,
url: serverUrl,
version: null,
});
await invoke("storage_save_user", { await commands.storageSaveUser(
id: authResult.user.id, authResult.user.id,
serverId: authResult.serverId, authResult.serverId,
username: authResult.user.name, authResult.user.name,
accessToken: authResult.accessToken, authResult.accessToken
}); );
await invoke("storage_set_active_user", { await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
userId: authResult.user.id,
serverId: authResult.serverId,
});
// Set session in auth manager with server name // Set session in auth manager with server name
await invoke("auth_set_session", { await commands.authSetSession({
session: { userId: authResult.user.id,
userId: authResult.user.id, username: authResult.user.name,
username: authResult.user.name, serverId: authResult.serverId,
serverId: authResult.serverId, serverUrl,
serverUrl, serverName,
serverName, accessToken: authResult.accessToken,
accessToken: authResult.accessToken, verified: true,
verified: true, needsReauth: false,
needsReauth: false,
},
}); });
// Create RepositoryClient // Create RepositoryClient
@@ -352,12 +315,12 @@ function createAuthStore() {
// Configure Rust player // Configure Rust player
try { try {
const playerDeviceId = await getDeviceId(); const playerDeviceId = await getDeviceId();
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl, serverUrl,
accessToken: authResult.accessToken, authResult.accessToken,
userId: authResult.user.id, authResult.user.id,
deviceId: playerDeviceId, playerDeviceId
}); );
console.log("[Auth] Rust player configured for playback reporting"); console.log("[Auth] Rust player configured for playback reporting");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to configure Rust player:", error); console.error("[Auth] Failed to configure Rust player:", error);
@@ -380,7 +343,7 @@ function createAuthStore() {
// Start background verification // Start background verification
try { try {
const verifyDeviceId = await getDeviceId(); const verifyDeviceId = await getDeviceId();
await invoke("auth_start_verification", { deviceId: verifyDeviceId }); await commands.authStartVerification(verifyDeviceId);
} catch (error) { } catch (error) {
console.error("[Auth] Failed to start verification:", error); console.error("[Auth] Failed to start verification:", error);
} }
@@ -404,25 +367,22 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Re-authenticating..."); console.log("[Auth] Re-authenticating...");
const authResult = await invoke<AuthResult>("auth_reauthenticate", { const authResult = await commands.authReauthenticate(password, deviceId);
password,
deviceId,
});
console.log("[Auth] Re-authentication successful"); console.log("[Auth] Re-authentication successful");
// Update storage // Update storage
await invoke("storage_save_user", { await commands.storageSaveUser(
id: authResult.user.id, authResult.user.id,
serverId: authResult.serverId, authResult.serverId,
username: authResult.user.name, authResult.user.name,
accessToken: authResult.accessToken, authResult.accessToken
}); );
// Recreate repository with new credentials // Recreate repository with new credentials
if (repository) { if (repository) {
await repository.destroy(); await repository.destroy();
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
if (session) { if (session) {
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
} }
@@ -431,12 +391,12 @@ function createAuthStore() {
// Reconfigure player // Reconfigure player
try { try {
const playerDeviceId = await getDeviceId(); const playerDeviceId = await getDeviceId();
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl: repository ? await getCurrentSessionServerUrl() : "", repository ? await getCurrentSessionServerUrl() : "",
accessToken: authResult.accessToken, authResult.accessToken,
userId: authResult.user.id, authResult.user.id,
deviceId: playerDeviceId, playerDeviceId
}); );
} catch (error) { } catch (error) {
console.error("[Auth] Failed to reconfigure player:", error); console.error("[Auth] Failed to reconfigure player:", error);
} }
@@ -467,19 +427,15 @@ function createAuthStore() {
*/ */
async function logout() { async function logout() {
try { try {
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
if (session) { if (session) {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
await invoke("auth_logout", { await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
serverUrl: session.serverUrl,
accessToken: session.accessToken,
deviceId,
});
} }
// Disable Jellyfin reporting in player // Disable Jellyfin reporting in player
try { try {
await invoke("player_disable_jellyfin"); await commands.playerDisableJellyfin();
} catch (error) { } catch (error) {
console.error("[Auth] Failed to disable player reporting:", error); console.error("[Auth] Failed to disable player reporting:", error);
} }
@@ -524,7 +480,7 @@ function createAuthStore() {
*/ */
async function getCurrentSession() { async function getCurrentSession() {
try { try {
return await invoke<Session | null>("auth_get_session"); return await commands.authGetSession();
} catch (error) { } catch (error) {
console.error("[Auth] Failed to get current session:", error); console.error("[Auth] Failed to get current session:", error);
return null; return null;
@@ -551,7 +507,7 @@ function createAuthStore() {
* Helper to get server URL from current session. * Helper to get server URL from current session.
*/ */
async function getCurrentSessionServerUrl(): Promise<string> { async function getCurrentSessionServerUrl(): Promise<string> {
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
return session?.serverUrl || ""; return session?.serverUrl || "";
} }
@@ -562,7 +518,7 @@ function createAuthStore() {
try { try {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Retrying session verification after reconnection"); console.log("[Auth] Retrying session verification after reconnection");
await invoke("auth_start_verification", { deviceId }); await commands.authStartVerification(deviceId);
} catch (error) { } catch (error) {
console.error("[Auth] Failed to retry verification:", error); console.error("[Auth] Failed to retry verification:", error);
} }
+36 -67
View File
@@ -1,13 +1,18 @@
// Connectivity state store for offline support // Connectivity state store for offline support
// //
// Simplified wrapper over Rust connectivity monitor. // Pure reflection of the Rust ConnectivityMonitor. Reachability is the single
// The Rust backend handles all polling, reachability checks, and adaptive intervals. // source of truth in Rust, derived from real repository traffic (success/
// RepoError), with a time-window debounce before going offline and instant
// recovery. This store only listens for `connectivity:changed` events and
// mirrors status; it does not decide reachability itself. navigator.onLine is
// advisory and triggers a recheck rather than forcing offline.
// See docs/architecture/07-connectivity.md.
// TRACES: UR-002 | DR-013 // TRACES: UR-002 | DR-013
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
export interface ConnectivityState { export interface ConnectivityState {
/** Browser's navigator.onLine status */ /** Browser's navigator.onLine status */
@@ -29,13 +34,6 @@ export interface ConnectivityEvents {
onServerReconnected?: () => void; onServerReconnected?: () => void;
} }
interface RustConnectivityStatus {
isServerReachable: boolean;
lastChecked: string | null;
connectionError: string | null;
isChecking: boolean;
}
function createConnectivityStore() { function createConnectivityStore() {
const initialState: ConnectivityState = { const initialState: ConnectivityState = {
isOnline: browser ? navigator.onLine : true, isOnline: browser ? navigator.onLine : true,
@@ -68,22 +66,27 @@ function createConnectivityStore() {
} }
}); });
// Listen to browser online/offline events and update state // Listen to browser online/offline events. These are ADVISORY only — the
// Rust ConnectivityMonitor (fed by real repository traffic) is the source of
// truth for server reachability. navigator.onLine can be wrong (e.g. a
// LAN-only server is still reachable while the browser reports "offline"),
// so we use these events to trigger an immediate recheck rather than forcing
// the offline state. See docs/architecture/07-connectivity.md.
window.addEventListener("online", () => { window.addEventListener("online", () => {
update((s) => ({ ...s, isOnline: true })); update((s) => ({ ...s, isOnline: true }));
// Device regained network — ask the backend to re-verify the server now.
checkServerReachable().catch((err) => {
console.debug("[ConnectivityStore] Recheck after 'online' failed:", err);
});
}); });
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
update((s) => ({ // Reflect the browser's view of the device link, but let the backend
...s, // decide whether the server is actually reachable.
isOnline: false, update((s) => ({ ...s, isOnline: false }));
isServerReachable: false, checkServerReachable().catch((err) => {
connectionError: "Device is offline", console.debug("[ConnectivityStore] Recheck after 'offline' failed:", err);
})); });
if (eventHandlers.onConnectivityChange) {
eventHandlers.onConnectivityChange(false);
}
}); });
} }
@@ -115,10 +118,10 @@ function createConnectivityStore() {
*/ */
async function checkServerReachable(): Promise<boolean> { async function checkServerReachable(): Promise<boolean> {
try { try {
const isReachable = await invoke<boolean>("connectivity_check_server"); const isReachable = await commands.connectivityCheckServer();
// Fetch updated status from Rust // Fetch updated status from Rust
const status = await invoke<RustConnectivityStatus>("connectivity_get_status"); const status = await commands.connectivityGetStatus();
update((s) => ({ update((s) => ({
...s, ...s,
isServerReachable: status.isServerReachable, isServerReachable: status.isServerReachable,
@@ -145,13 +148,13 @@ function createConnectivityStore() {
console.log("[ConnectivityStore] Starting monitoring for:", url); console.log("[ConnectivityStore] Starting monitoring for:", url);
// Set the server URL // Set the server URL
await invoke("connectivity_set_server_url", { url }); await commands.connectivitySetServerUrl(url);
// Start the Rust monitoring task (performs immediate check) // Start the Rust monitoring task (performs immediate check)
await invoke("connectivity_start_monitoring"); await commands.connectivityStartMonitoring();
// Get the initial status immediately after starting // Get the initial status immediately after starting
const status = await invoke<RustConnectivityStatus>("connectivity_get_status"); const status = await commands.connectivityGetStatus();
update((s) => ({ update((s) => ({
...s, ...s,
isServerReachable: status.isServerReachable, isServerReachable: status.isServerReachable,
@@ -179,7 +182,7 @@ function createConnectivityStore() {
if (!isMonitoring) return; if (!isMonitoring) return;
try { try {
await invoke("connectivity_stop_monitoring"); await commands.connectivityStopMonitoring();
isMonitoring = false; isMonitoring = false;
eventHandlers = {}; eventHandlers = {};
console.log("[ConnectivityStore] Stopped monitoring"); console.log("[ConnectivityStore] Stopped monitoring");
@@ -193,7 +196,7 @@ function createConnectivityStore() {
*/ */
async function setServerUrl(url: string): Promise<void> { async function setServerUrl(url: string): Promise<void> {
try { try {
await invoke("connectivity_set_server_url", { url }); await commands.connectivitySetServerUrl(url);
} catch (error) { } catch (error) {
console.error("[ConnectivityStore] Failed to set server URL:", error); console.error("[ConnectivityStore] Failed to set server URL:", error);
} }
@@ -206,43 +209,11 @@ function createConnectivityStore() {
return checkServerReachable(); return checkServerReachable();
} }
/** // Reachability is now driven by the Rust backend from real repository
* Mark server as reachable (e.g., after successful API call) // traffic (see docs/architecture/07-connectivity.md). The frontend no longer
*/ // mutates reachability itself, so the former markReachable/markUnreachable
async function markReachable(): Promise<void> { // helpers have been removed. The underlying Rust commands remain available
try { // for deliberate signals if ever needed.
await invoke("connectivity_mark_reachable");
// Update local state
update((s) => ({
...s,
isServerReachable: true,
lastChecked: new Date(),
connectionError: null,
}));
} catch (error) {
console.error("[ConnectivityStore] Failed to mark reachable:", error);
}
}
/**
* Mark server as unreachable (e.g., after failed API call)
*/
async function markUnreachable(error?: string): Promise<void> {
try {
await invoke("connectivity_mark_unreachable", { error: error ?? null });
// Update local state
update((s) => ({
...s,
isServerReachable: false,
lastChecked: new Date(),
connectionError: error || "Server unreachable",
}));
} catch (err) {
console.error("[ConnectivityStore] Failed to mark unreachable:", err);
}
}
return { return {
subscribe, subscribe,
@@ -251,8 +222,6 @@ function createConnectivityStore() {
setServerUrl, setServerUrl,
forceCheck, forceCheck,
checkServerReachable, checkServerReachable,
markReachable,
markUnreachable,
isNetworkError, isNetworkError,
}; };
} }

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