Compare commits

...
31 Commits
Author SHA1 Message Date
dtourolle 1f6977cd01 Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
2026-07-02 18:13:55 +02:00
dtourolle 6af7f7dcca Fix android playback issue
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m13s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m46s
2026-07-02 00:19:07 +02:00
dtourolle 75014ee00f Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
2026-07-01 23:49:51 +02:00
dtourolleandClaude Opus 4.8 342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
  controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
  auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
  progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
  mirroring the MPV backend.

Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
  track over the runTimeTicks estimate, so pausing no longer clobbers the
  slider's max to 0 when runTimeTicks is missing.

Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
  discriminator, so a video started via player_play_item (no Jellyfin `type`,
  mediaType "video") no longer surfaces in the audio mini player.

Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
  distinguishing tails (episode numbers, suffixes) stay visible.

Adds regression tests for the duration and mini-player fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:52:27 +02:00
dtourolle dcee342c47 Jray mugshots of actors shown
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 14m55s
Traceability Validation / Check Requirement Traces (push) Successful in 51s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 26m44s
2026-06-28 21:09:06 +02:00
dtourolle 78f5cd9db9 Fix playback regression
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-06-28 21:07:00 +02:00
dtourolle 0eae81ec59 Add JRay support
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m59s
Traceability Validation / Check Requirement Traces (push) Successful in 1m48s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:38:58 +02:00
dtourolle 8eae4ae253 layout improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 9m2s
Traceability Validation / Check Requirement Traces (push) Successful in 2m30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:14:17 +02:00
dtourolle ef7be645b3 Merge pull request 'fix/lockscreen-mediasession-sync' (#7) from fix/lockscreen-mediasession-sync into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m47s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m40s
Reviewed-on: #7
2026-06-27 21:57:22 +00:00
dtourolle b9249f72e9 rescale logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m27s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-27 23:56:36 +02:00
dtourolleandClaude Opus 4.8 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.

- Make MediaSessionCompat the single source of truth; route all transport
  commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
  via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
  and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
  session poller (works while the screen is locked, unlike WebView timers) via
  a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
  prev/seek to the remote Jellyfin session; Stop while casting emits
  RemoteDisconnectRequested, which the frontend handles by transferring to local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:26 +02:00
dtourolle 345bd0730c Merge pull request 'feat/plugin-channel-support' (#6) from feat/plugin-channel-support into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m40s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m27s
Build & Release / Run Tests (push) Successful in 4m6s
Build & Release / Build Linux (push) Successful in 16m11s
Build & Release / Build Android (push) Successful in 18m47s
Build & Release / Create Release (push) Successful in 13s
Reviewed-on: #6
2026-06-27 15:52:39 +00:00
dtourolle e1e50d51e0 Use different app logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m1s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m52s
Build & Release / Build Linux (push) Successful in 16m22s
Build & Release / Build Android (push) Successful in 19m13s
Build & Release / Create Release (push) Successful in 10s
2026-06-27 17:43:08 +02:00
dtourolle 7d7f27aa10 feat(library and playback): Support for serverside channel plugins and hls streaming 2026-06-27 17:25:57 +02:00
dtourolle f1d25c4f4d Add support for fusing/unfusing JellyLMS zones into synchronized
multi-room groups, addressed by MAC (derived from the `lms-{mac}` device id).
2026-06-26 19:27:37 +02:00
dtourolle ff8f35084b Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m54s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m6s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m38s
Build & Release / Build Linux (push) Successful in 16m2s
Build & Release / Build Android (push) Successful in 18m54s
Build & Release / Create Release (push) Successful in 11s
Reviewed-on: #5
2026-06-25 21:52:13 +00:00
dtourolle 4634ed595c fix(remote playback): Move audio between remote players
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m37s
2026-06-25 23:39:01 +02:00
dtourolle 6836ce79c8 fix(Remote playback): kludge to scrub after stream move
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m18s
2026-06-25 21:31:39 +02:00
dtourolle 2811e1b7ca fix: several small fixes
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m51s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m26s
2026-06-25 20:02:01 +02:00
dtourolle 1836615dc0 feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00
dtourolle 62874564ff Merge pull request 'feat(library): focused music/TV/movie landing screens + self-draining download queue' (#4) from feat/library-screens-and-download-queue into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m44s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
Build & Release / Run Tests (push) Successful in 2m36s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m41s
Build & Release / Build Linux (push) Successful in 16m23s
Build & Release / Build Android (push) Successful in 18m39s
Build & Release / Create Release (push) Successful in 12s
Reviewed-on: #4
2026-06-24 20:07:34 +00:00
dtourolle 17a35573a0 feat(library): focused music/TV/movie landing screens + self-draining download queue
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 9m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 25s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 22m33s
Library screens:
- Add dedicated music, TV, and movie landing pages (hero banner +
  horizontal carousels) backed by new music/tv/movies stores.
- Route tvshows libraries to /library/tv; surface rediscover ("haven't
  listened to in a while") albums via a new repository method across
  online/offline/hybrid repos plus the repository_get_rediscover_albums
  command.
- Add an A-Z jump bar for long alphabetically-sorted lists, with grid
  index anchors in LibraryGrid/LibraryListView/TrackList.
- Filter the "Podcasts" folder out of music library queries.

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

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

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

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

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

Unify into a single source of truth:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:44:23 +02:00
dtourolle 0c3ed74fe1 ci: Improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m47s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 2m27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 28m0s
Build & Release / Build Linux (push) Successful in 15m45s
Build & Release / Build Android (push) Failing after 49s
Build & Release / Create Release (push) Has been skipped
2026-06-21 17:38:06 +02:00
dtourolle a5b6266a6d Permanent CI fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m54s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 1h14m25s
Build & Release / Run Tests (push) Failing after 7s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-06-21 10:48:26 +02:00
dtourolle a816c84f8c fix CI
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:35:14 +02:00
dtourolle 87762c03b6 Fix linux playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m38s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:16:25 +02:00
143 changed files with 8582 additions and 1284 deletions
+5 -5
View File
@@ -31,9 +31,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -82,9 +82,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-android-
- name: Cache Node dependencies
uses: actions/cache@v3
@@ -122,7 +122,7 @@ jobs:
id: build
run: |
mkdir -p artifacts
bun run tauri android build --apk true
bun run tauri android build --apk true --target aarch64
# Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
+79 -103
View File
@@ -18,37 +18,40 @@ jobs:
test:
name: Run Tests
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
- name: Run frontend tests
run: bun run test --run
run: |
bunx svelte-kit sync
bun run test --run
continue-on-error: false
- name: Run Rust tests
@@ -63,45 +66,32 @@ jobs:
name: Build Linux
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-host-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
@@ -135,59 +125,40 @@ jobs:
name: Build Android
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
with:
api-level: 33
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Add Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Install Android NDK
run: |
sdkmanager "ndk;25.1.8937393"
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
- name: Resolve Android NDK path
run: echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/25.1.8937393" >> "$GITHUB_ENV"
- name: Set app version from tag
run: |
REF="${GITHUB_REF#refs/tags/v}"
@@ -201,8 +172,6 @@ jobs:
- name: Initialize Android project
run: bun run tauri android init
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
@@ -218,11 +187,7 @@ jobs:
EOF
- name: Build signed Android APK
run: bun run tauri android build --apk
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
ANDROID_SDK_ROOT: ${{ env.ANDROID_SDK_ROOT }}
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
run: bun run tauri android build --apk true --target aarch64
- name: Collect & verify signed APK
run: |
@@ -247,6 +212,8 @@ jobs:
runs-on: linux/amd64
needs: [build-linux, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -273,23 +240,23 @@ jobs:
id: release_notes
run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}"
echo "## 📱 JellyTau $VERSION Release" > release_notes.md
echo "## JellyTau $VERSION Release" > release_notes.md
echo "" >> release_notes.md
echo "### 📦 Downloads" >> release_notes.md
echo "### Downloads" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux" >> release_notes.md
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
echo "- **DEB** - Install via `sudo dpkg -i jellytau_*.deb` (Ubuntu/Debian)" >> release_notes.md
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- **APK** - Install via `adb install jellytau-release.apk` or sideload via file manager" >> release_notes.md
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
echo "" >> release_notes.md
echo "### What's New" >> release_notes.md
echo "### What's New" >> release_notes.md
echo "" >> release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
echo "" >> release_notes.md
echo "### 🔧 Installation" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux (AppImage)" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
@@ -307,11 +274,11 @@ jobs:
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
echo "- Play Store: Coming soon" >> release_notes.md
echo "" >> release_notes.md
echo "### 🐛 Known Issues" >> release_notes.md
echo "### Known Issues" >> release_notes.md
echo "" >> release_notes.md
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
echo "" >> release_notes.md
echo "### 📝 Requirements" >> release_notes.md
echo "### Requirements" >> release_notes.md
echo "" >> release_notes.md
echo "**Linux:**" >> release_notes.md
echo "- 64-bit Linux system" >> release_notes.md
@@ -322,7 +289,7 @@ jobs:
echo "- 50MB free storage" >> release_notes.md
echo "" >> release_notes.md
echo "---" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
- name: Publish Gitea release & upload assets
env:
@@ -346,11 +313,20 @@ jobs:
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
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 "Content-Type: application/json" \
-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"
for f in artifacts/android/* artifacts/linux/*; do
+15 -7
View File
@@ -95,20 +95,28 @@ jobs:
echo ""
# Check each file
MISSING_TRACES=0
while IFS= read -r file; do
# Pipe into the loop instead of a here-string (<<<) so this step works
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
# for the same reason. The loop runs in a subshell (so a counter var
# wouldn't survive), so we record warnings in a temp file and count it
# afterwards.
MISSING_FILE=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
# Skip test files
if [[ "$file" == *".test."* ]]; then
continue
fi
case "$file" in
*.test.*) continue ;;
esac
if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1))
echo "$file" >> "$MISSING_FILE"
fi
fi
done <<< "$CHANGED"
done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then
echo ""
+8 -3
View File
@@ -7,7 +7,8 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \
SDK_VERSION=36 \
BUILD_TOOLS_VERSION=35.0.0 \
RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo
@@ -68,10 +69,14 @@ RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-1107
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
# Install Android SDK components
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platform-tools" \
"platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \
"build-tools;$BUILD_TOOLS_VERSION" \
"ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true
+4 -1
View File
@@ -1,4 +1,7 @@
# JellyTau
<h1 align="center">
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
JellyTau
</h1>
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
+6 -3
View File
@@ -176,7 +176,10 @@ classDiagram
-server_url: String
-user_id: String
-access_token: String
-connectivity: Option~Arc~ConnectivityMonitor~~
+new()
+with_connectivity()
-report_outcome()
}
class OfflineRepository {
@@ -192,10 +195,9 @@ classDiagram
class HybridRepository {
-online: Arc~OnlineRepository~
-offline: Arc~OfflineRepository~
-connectivity: Arc~ConnectivityMonitor~
+new()
-parallel_query()
-has_meaningful_content()
-parallel_race()
-cache_with_timeout()
}
MediaRepository <|.. OnlineRepository
@@ -214,6 +216,7 @@ classDiagram
- Returns cache result if it has meaningful content
- Falls back to server result otherwise
- Background cache updates planned
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
2. **Handle-Based Resource Management** (`repository.rs` commands):
```rust
+9
View File
@@ -10,6 +10,7 @@ sequenceDiagram
participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP)
participant Conn as ConnectivityMonitor
UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId})
@@ -20,6 +21,13 @@ sequenceDiagram
Hybrid->>Server: get_items() (no timeout)
end
Note over Server,Conn: Every server request reports its outcome
alt Server succeeds (or answers with 4xx/5xx)
Server->>Conn: mark_reachable() (server is up)
else Network failure / timeout
Server->>Conn: mark_unreachable() (debounced)
end
alt Cache returns with content
Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result
@@ -39,6 +47,7 @@ sequenceDiagram
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
## Playback Initiation Flow
+61 -26
View File
@@ -13,12 +13,13 @@ pub struct HttpClient {
}
pub struct HttpConfig {
pub base_url: String,
pub timeout: Duration, // Default: 10s
pub timeout: Duration, // Default: 30s (large library queries can be slow)
pub max_retries: u32, // Default: 3
}
```
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
**Retry Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors
@@ -38,39 +39,71 @@ pub enum ErrorKind {
**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
flowchart TB
Monitor["ConnectivityMonitor"] --> Poller["Background Task"]
Poller --> Check{"Server<br/>Reachable?"}
Check -->|"Yes"| Online["30s Interval"]
Check -->|"No"| Offline["5s Interval"]
Online --> Emit["Emit Events"]
Offline --> Emit
Emit --> Frontend["Frontend Store"]
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
Monitor --> State{"is_server_reachable?"}
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
Probe -->|"reachable again"| Monitor
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
Emit --> Frontend["Frontend Store → banner"]
```
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
**Features:**
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection)
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events
- **Manual Marking**: Can mark reachable/unreachable based on API call results
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state
- **Traffic-driven**: Reachability follows the requests the user actually makes.
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
**Tauri Commands:**
| Command | Description |
|---------|-------------|
| `connectivity_check_server` | Manual reachability check |
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
| `connectivity_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start background monitoring |
| `connectivity_stop_monitoring` | Stop monitoring |
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) |
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) |
| `connectivity_start_monitoring` | Start the offline recovery probe |
| `connectivity_stop_monitoring` | Stop the probe |
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
**Frontend Integration:**
```typescript
// 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) => {
updateConnectivityState(event.payload.isReachable);
});
@@ -81,12 +114,14 @@ listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Background reachability checks
3. **Frontend Integration**: Offline mode detection and UI updates
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:**
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication)
- **Fail Slow**: Retry network and 5xx errors with increasing delays
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline
- **Event-Driven**: Frontend reacts to connectivity changes via events
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
- **Event-Driven**: Frontend reacts to connectivity changes via events.
+9 -1
View File
@@ -13,8 +13,10 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
@@ -79,9 +81,12 @@ flowchart TB
Core --> Storage
Repository --> HttpClient
Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end
```
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
---
## Detailed Documentation
@@ -162,6 +167,9 @@ src/lib/
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
│ ├── client.ts # JellyfinClient (helper for streaming)
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
@@ -187,7 +195,7 @@ src/lib/
**What moved to Rust (~3,500 lines of business logic):**
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
2. **Connectivity Monitor** (301 lines) - 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
4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+8 -1
View File
@@ -2028,6 +2028,7 @@ dependencies = [
"tokio",
"tokio-rusqlite",
"tokio-util",
"urlencoding",
"uuid",
]
@@ -4959,6 +4960,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -5281,7 +5288,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[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-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
urlencoding = "2"
futures-util = "0.3"
async-trait = "0.1"
@@ -0,0 +1,114 @@
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
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = 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
// Re-fit the video whenever the content view's bounds change (e.g. on
// device rotation) so the video is letterboxed to fit instead of being
// stretched/cropped by the MATCH_PARENT surface.
removeLayoutListener()
val listener = android.view.View.OnLayoutChangeListener {
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
player.fitSurfaceToScreen()
}
}
contentView.addOnLayoutChangeListener(listener)
contentLayoutListener = listener
listenerContentView = contentView
// Fit once now that the surface is attached and the parent is sized.
player.fitSurfaceToScreen()
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 {
removeLayoutListener()
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
}
private fun removeLayoutListener() {
contentLayoutListener?.let { listener ->
listenerContentView?.removeOnLayoutChangeListener(listener)
}
contentLayoutListener = null
listenerContentView = null
}
}
@@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands
// Wrap the ExoPlayer to intercept commands from Media3 controllers
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
// session rather than the MediaSessionCompat).
//
// We do NOT execute on ExoPlayer directly here. Every transport command
// is routed to Rust via nativeOnMediaCommand, which is the single decision
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
// would double-handle local commands and incorrectly drive the local
// player while casting.
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play")
}
override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause")
}
override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next")
}
override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous")
}
override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop")
}
}
@@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
)
isActive = true
// Set callback to handle lock screen button presses
// Set callback to handle lock screen button presses.
//
// All transport commands are routed through Rust via nativeOnMediaCommand
// rather than directly to ExoPlayer. Rust is the single decision point:
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
// the command to the remote Jellyfin session. This keeps the lockscreen
// working identically for both, and avoids the ExoPlayer-only behaviour
// that left remote playback uncontrollable from the lockscreen.
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play()
nativeOnMediaCommand("play")
}
override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause()
nativeOnMediaCommand("pause")
}
override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext()
nativeOnMediaCommand("next")
}
override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious()
nativeOnMediaCommand("previous")
}
override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop()
nativeOnMediaCommand("stop")
}
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position)
val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
})
}
@@ -253,9 +252,19 @@ class JellyTauPlaybackService : MediaSessionService() {
.build()
}
// Last-known metadata/state, retained so lightweight position ticks can
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
// and notification. Kept in sync by updateMediaMetadata().
private var lastTitle: String = ""
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
/**
* Update the MediaSession metadata and playback state.
* This updates both the MediaSession and the notification.
* Update the MediaSession metadata and playback state, plus the notification.
*
* Call this when the track or play/pause state changes. For frequent position
* updates during playback, use [updatePlaybackPosition] instead, which is much
* cheaper (no metadata rebuild, no notification rebuild).
*/
fun updateMediaMetadata(
title: String,
@@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
) {
val session = mediaSessionCompat ?: return
lastTitle = title
lastArtist = artist
lastIsPlaying = isPlaying
// Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
@@ -280,7 +293,42 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder()
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update only the playback position (and play/pause state) on the MediaSession.
*
* This is the cheap path used for the periodic (250ms) position ticks: it
* refreshes the lockscreen scrubber without rebuilding metadata or the
* notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback.
*
* @param position Position in milliseconds
* @param isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
}
}
/**
* Build a PlaybackStateCompat with the standard transport actions.
*
* The reported playback speed is 1.0 while playing and 0.0 while paused so
* Android does not extrapolate the position past a paused track.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
return PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
@@ -292,13 +340,9 @@ class JellyTauPlaybackService : MediaSessionService() {
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position,
1.0f
if (isPlaying) 1.0f else 0.0f
)
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
.build()
}
/**
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */
private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */
private var currentTitle: String = ""
private var currentArtist: String = ""
@@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
/** SurfaceView for video playback */
private var surfaceView: SurfaceView? = null
private var surfaceHolder: SurfaceHolder? = null
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0
private var videoHeight: Int = 0
private var currentMediaType: MediaType = MediaType.AUDIO
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
@@ -171,6 +183,12 @@ class JellyTauPlayer(private val appContext: Context) {
// Create ExoPlayer with audio focus handling
exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
// the lockscreen notification in sync automatically.
.setHandleAudioBecomingNoisy(true)
.build()
// Set up player listener
@@ -202,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates()
nativeOnPlaybackEnded()
// Only notify the backend once per loaded media. ExoPlayer
// can re-enter STATE_ENDED, which would double-count things
// like the sleep-timer episode counter.
if (!endedNotified) {
endedNotified = true
nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
}
Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@@ -239,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
}
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}")
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
// Apply pixel aspect ratio so anamorphic content isn't distorted
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
videoHeight = videoSize.height
fitSurfaceToScreen()
}
override fun onRenderedFirstFrame() {
@@ -316,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
@@ -546,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
) {
mainHandler.post {
currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates
currentTitle = title
@@ -735,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) {
if (exoPlayer.isPlaying) {
val position = exoPlayer.currentPosition / 1000.0
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
val position = positionMs / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync.
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
}
delay(POSITION_UPDATE_INTERVAL_MS)
}
@@ -842,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
/**
* Resize the video surface (for orientation changes).
*
* Re-fits the surface to the screen preserving the video's aspect ratio so
* nothing is cropped when the device rotates.
*/
fun resizeSurface(width: Int, height: Int) {
fitSurfaceToScreen()
}
/**
* Size the video SurfaceView so the video fits entirely inside its parent
* (the full-screen content view) while preserving aspect ratio (letterbox/
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
* video to the surface bounds, which crops the bottom on rotation.
*/
fun fitSurfaceToScreen() {
mainHandler.post {
surfaceView?.let { view ->
view.layoutParams = view.layoutParams.apply {
this.width = width
this.height = height
}
view.requestLayout()
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
val view = surfaceView ?: return@post
val parent = view.parent as? ViewGroup
// Available area: prefer the parent's measured size, fall back to the screen.
val availW = parent?.width?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.widthPixels
val availH = parent?.height?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.heightPixels
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
return@post
}
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
val viewAspect = availW.toFloat() / availH.toFloat()
val targetW: Int
val targetH: Int
if (videoAspect > viewAspect) {
// Video is wider than the screen → fit width, letterbox top/bottom
targetW = availW
targetH = (availW / videoAspect).toInt()
} else {
// Video is taller than the screen → fit height, pillarbox sides
targetH = availH
targetW = (availH * videoAspect).toInt()
}
val lp = view.layoutParams
// FrameLayout child: center the fitted surface within the full-screen parent.
if (lp is FrameLayout.LayoutParams) {
lp.gravity = android.view.Gravity.CENTER
}
lp.width = targetW
lp.height = targetH
view.layoutParams = lp
view.requestLayout()
android.util.Log.d(
"JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+332 -46
View File
@@ -2,8 +2,9 @@
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::State;
use tauri::{Manager, State};
use log::{debug, error, info, warn};
use crate::download::{DownloadInfo, DownloadManager};
@@ -818,9 +819,7 @@ pub async fn start_download(
stream_url: String,
target_dir: String,
) -> Result<(), String> {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use std::path::PathBuf;
use tauri::Emitter;
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 {
Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?",
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)],
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::Int64(size),
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
)
} else {
Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(download_id)],
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE 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
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
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
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.
pub(crate) 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 {
debug!("Download task started for download_id: {}", download_id);
let worker = DownloadWorker::new();
// Progress callback that emits events to the frontend
let progress_app = app_clone.clone();
let progress_item_id = item_id_clone.clone();
let progress_app = app.clone();
let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
@@ -975,54 +1260,55 @@ pub async fn start_download(
let _ = progress_app.emit("download-event", event);
};
match worker.download(&task, on_progress).await {
Ok(result) => {
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
let result = worker.download(&task, on_progress).await;
// Unregister from download manager
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
}
// Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
}
// 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 {
download_id,
item_id: item_id_clone,
item_id,
file_path: target_path.to_string_lossy().to_string(),
};
debug!("Emitting completed event: {:?}", completed_event);
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
match app_clone.emit("download-event", completed_event) {
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
Err(e) => error!(" Completed event emit failed: {:?}", e),
}
}
Err(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 {
download_id,
item_id: item_id_clone.clone(),
item_id,
error: e.to_string(),
};
debug!("Emitting failed event: {:?}", failed_event);
match app_clone.emit("download-event", failed_event) {
match app.emit("download-event", failed_event) {
Ok(_) => debug!(" Failed event emitted successfully"),
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
+21 -3
View File
@@ -41,12 +41,30 @@ pub fn playback_mode_is_transferring(
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
position: Option<f64>,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id,
position
);
manager.0.transfer_to_remote(session_id).await
manager.0.transfer_to_remote(session_id, position).await
}
/// Set the transferring flag on the playback mode manager.
///
/// Used by the frontend remote->local flow to mark the whole two-step sequence
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
/// casting back to the remote session it's leaving. Always pair `true` with a
/// later `false` (including on error) so the flag can't stick.
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_set_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
transferring: bool,
) -> Result<(), String> {
manager.0.set_transferring(transferring);
Ok(())
}
/// Transfer playback from remote session back to local device
+345 -25
View File
@@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize,
pub shuffle: bool,
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
@@ -374,6 +378,65 @@ pub(super) async fn check_for_local_download(
}
}
/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
/// while it plays (preloaded upcoming tracks) — or that existed before the
/// connection dropped — would otherwise keep streaming. Called before advancing
/// so the next track always prefers the on-disk copy.
///
/// Returns the number of items switched to a local source.
pub(super) async fn refresh_queue_local_sources(
controller: &PlayerController,
db: &DatabaseWrapper,
) -> Result<usize, String> {
// Collect remote item IDs first; the queue lock must not be held across awaits.
let remote_ids: Vec<String> = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.items()
.iter()
.filter_map(|item| match &item.source {
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id.clone()),
_ => None,
})
.collect()
};
if remote_ids.is_empty() {
return Ok(0);
}
let mut local_paths: Vec<(String, String)> = Vec::new();
for id in remote_ids {
if let Some(path) = check_for_local_download(db, &id).await? {
local_paths.push((id, path));
}
}
if local_paths.is_empty() {
return Ok(0);
}
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
let mut switched = 0;
for item in queue_lock.items_mut() {
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
info!("[Player] Switching queued track {} to local download: {}", id, path);
item.source = MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(id.clone()),
};
switched += 1;
}
}
}
Ok(switched)
}
/// Play a single media item (audio or video)
///
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
@@ -675,6 +738,7 @@ pub async fn player_next(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
debug!("[player_next] Command called from frontend");
@@ -693,6 +757,10 @@ pub async fn player_next(
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_next] Failed to refresh local sources: {}", e);
}
controller.next().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
@@ -728,6 +796,7 @@ pub async fn player_previous(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
@@ -744,6 +813,10 @@ pub async fn player_previous(
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_previous] Failed to refresh local sources: {}", e);
}
controller.previous().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
@@ -1245,6 +1318,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
#[tauri::command]
@@ -1254,6 +1380,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> {
@@ -1376,11 +1503,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone());
}
// Play the queue
let controller = player.0.lock().await;
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
// When controlling a remote session, cast the selection there instead of
// 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
{
@@ -1422,6 +1562,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayTracksRequest,
) -> Result<PlayerStatus, String> {
@@ -1529,10 +1670,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone());
}
// Play queue
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
{
@@ -1574,12 +1738,23 @@ pub async fn player_preload_upcoming(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>,
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
app: tauri::AppHandle,
user_id: String,
_download_base_path: String,
) -> Result<PreloadResult, String> {
let db_service = {
// The pump only starts rows that carry both a stream URL and a target dir,
// so resolve the same storage root the user-initiated download paths use
// (storage_get_path = the database's parent directory).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
};
// Get cache settings
@@ -1617,10 +1792,12 @@ pub async fn player_preload_upcoming(
// Process each upcoming item
for item in upcoming_items {
// Only process items with Remote source (not already local)
let jellyfin_id = match &item.source {
MediaSource::Remote { jellyfin_item_id, .. } => {
jellyfin_item_id.clone()
// Only process items with Remote source (not already local). The
// source already carries the resolved stream URL — reuse it so the
// pump can start the download without any extra resolution step.
let (jellyfin_id, stream_url) = match &item.source {
MediaSource::Remote { jellyfin_item_id, stream_url } => {
(jellyfin_item_id.clone(), stream_url.clone())
}
MediaSource::Local { .. } => {
already_downloaded += 1;
@@ -1632,9 +1809,13 @@ pub async fn player_preload_upcoming(
}
};
// Check if already downloaded
// Check if already downloaded or actively in flight. Stale pending rows
// without a stream URL are NOT skipped here — the upsert below heals
// them so the pump can finally start them.
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? AND status IN ('completed', 'downloading', 'pending') LIMIT 1",
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
AND (status IN ('completed', 'downloading')
OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
vec![
QueryParam::String(jellyfin_id.clone()),
QueryParam::String(user_id.clone()),
@@ -1651,14 +1832,29 @@ pub async fn player_preload_upcoming(
continue;
}
// Queue for download with low priority (preload priority = -100)
let file_path = format!("{}/{}.mp3", sanitize_filename(&item.album.clone().unwrap_or_default()), sanitize_filename(&item.title));
// Queue for download with low priority (preload priority = -100) so
// user-initiated downloads always win a pump slot first.
let album_dir = item
.album
.as_deref()
.filter(|a| !a.is_empty())
.unwrap_or("Unknown Album");
let file_path = format!(
"downloads/{}/{}.mp3",
sanitize_filename(album_dir),
sanitize_filename(&item.title)
);
// Insert download record with preload priority
// Insert with the stream URL + target dir the pump needs to start it.
// On conflict, heal pre-existing rows that were queued without a URL
// (they could never start) instead of leaving them stuck.
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name)
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?)
ON CONFLICT(item_id, user_id) DO NOTHING",
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
ON CONFLICT(item_id, user_id) DO UPDATE SET
stream_url = excluded.stream_url,
target_dir = excluded.target_dir
WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
vec![
QueryParam::String(jellyfin_id),
QueryParam::String(user_id.clone()),
@@ -1666,6 +1862,8 @@ pub async fn player_preload_upcoming(
QueryParam::String(item.title.clone()),
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
],
);
@@ -1685,6 +1883,16 @@ pub async fn player_preload_upcoming(
}
}
// Kick the pump so the queued preloads actually start; without this they'd
// only begin once some other download activity pumps the queue.
if queued_count > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
}
info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped);
Ok(PreloadResult {
@@ -1731,9 +1939,10 @@ pub async fn player_get_cache_config(
#[specta::specta]
pub async fn player_configure_jellyfin(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
access_token: String,
_user_id: String,
user_id: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
@@ -1744,12 +1953,31 @@ pub async fn player_configure_jellyfin(
device_id,
};
let client = JellyfinClient::new(config)?;
// Legacy client (used for remote session control / casting).
let client = JellyfinClient::new(config.clone())?;
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
// actually report through. Without this, Start/Progress/Stopped never reach
// Jellyfin, so playback position never syncs and you can't resume on another
// device. The reporter shares the player controller's Arc, so populating it
// here lights up reporting on both desktop and Android, on every auth path
// that configures the player (login / restore / reauth).
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let reporter_client = JellyfinClient::new(config)?;
let reporter = crate::playback_reporting::PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(reporter_client))),
user_id,
);
let controller = player.0.lock().await;
controller.set_jellyfin_client(Some(client));
controller.set_playback_reporter(Some(reporter)).await;
log::info!("[PlayerCommand] Jellyfin client configured successfully");
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
Ok(())
}
@@ -1763,13 +1991,105 @@ pub async fn player_disable_jellyfin(
let controller = player.0.lock().await;
controller.set_jellyfin_client(None);
controller.set_playback_reporter(None).await;
log::info!("[PlayerCommand] Jellyfin client disabled");
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
Ok(())
}
#[cfg(test)]
mod tests {
/// Queue items enqueued as Remote must flip to Local once a completed
/// download exists on disk — this is what makes preloaded tracks (and
/// offline playback after a connection drop) actually use the cache.
#[tokio::test]
async fn test_refresh_queue_local_sources_switches_completed_downloads() {
use super::{refresh_queue_local_sources, DatabaseWrapper};
use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
use crate::storage::Database;
use std::sync::Mutex;
// A real file on disk for the completed download; a missing file for
// the second entry to prove nonexistent files are not switched.
let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("track-a.mp3");
std::fs::write(&existing, b"audio").unwrap();
let missing = dir.join("track-b-missing.mp3");
let _ = std::fs::remove_file(&missing);
let database = Database::open_in_memory().unwrap();
{
let conn = database.connection();
let conn = conn.lock().unwrap();
conn.execute_batch(&format!(
r#"
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-a', 'user1', '{}', 'completed');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-b', 'user1', '{}', 'completed');
"#,
existing.display(),
missing.display()
))
.unwrap();
}
let db = DatabaseWrapper(Mutex::new(database));
let make_item = |id: &str| MediaItem {
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: format!("http://test/Audio/{}/stream", id),
jellyfin_item_id: id.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
let controller = PlayerController::default();
controller
.set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
.unwrap();
let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
assert_eq!(switched, 1, "only the download whose file exists switches");
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
match &queue_lock.items()[0].source {
MediaSource::Local { file_path, jellyfin_item_id } => {
assert_eq!(file_path, &existing);
assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
}
other => panic!("track-a should be local, got {:?}", other),
}
assert!(
matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
"track-b's file is missing, it must stay remote"
);
}
/// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13
#[test]
+6
View File
@@ -347,10 +347,16 @@ pub async fn player_add_tracks_by_ids(
#[specta::specta]
pub async fn player_skip_to(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
index: usize,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
}
// Skip to the index and get the item to play
let item = {
let queue = controller.queue();
+90
View File
@@ -6,6 +6,7 @@
use tauri::State;
use super::PlayerStateWrapper;
use crate::jellyfin::client::LmsSyncGroup;
/// Play items on a remote Jellyfin session (casting)
#[tauri::command]
@@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
Err("Jellyfin client not configured".to_string())
}
}
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
//
// The frontend addresses LMS players by MAC address, which it derives from a
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
// plugin REST API via the configured JellyfinClient.
/// List current LMS sync groups.
#[tauri::command]
#[specta::specta]
pub async fn lms_get_sync_groups(
player: State<'_, PlayerStateWrapper>,
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_get_sync_groups().await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
/// `slave_macs` zones join it in sync.
#[tauri::command]
#[specta::specta]
pub async fn lms_create_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_create_sync_group(&master_mac, slave_macs).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Remove a single LMS zone from its sync group (decouple one player).
#[tauri::command]
#[specta::specta]
pub async fn lms_unsync_player(
player: State<'_, PlayerStateWrapper>,
mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Decoupling zone {}", mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_unsync_player(&mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
#[tauri::command]
#[specta::specta]
pub async fn lms_dissolve_sync_group(
player: State<'_, PlayerStateWrapper>,
master_mac: String,
) -> Result<(), String> {
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
};
if let Some(client) = client_opt {
client.lms_dissolve_sync_group(&master_mac).await
} else {
Err("Jellyfin client not configured".to_string())
}
}
+62 -2
View File
@@ -142,6 +142,7 @@ pub async fn player_play_next_episode(
pub async fn player_on_playback_ended(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item_id: Option<String>,
repository_handle: Option<String>,
) -> Result<(), String> {
@@ -174,8 +175,14 @@ pub async fn player_on_playback_ended(
// Handle the decision
match decision {
AutoplayDecision::Stop => {
log::debug!("[Autoplay] Decision: Stop playback");
log::info!("[Autoplay] Decision: Stop playback");
let controller = controller_arc.lock().await;
// Clear the queue so the frontend's currentQueueItem becomes null and
// the mini player hides. Without this, the queue still holds the last
// track and the bar would linger (the frontend keeps the bar visible
// through transient idle blips as long as a queue item exists).
controller.clear_queue();
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
// Emit StateChanged to idle to clear the current media from mini player
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
@@ -187,9 +194,14 @@ pub async fn player_on_playback_ended(
}
}
AutoplayDecision::AdvanceToNext => {
log::debug!("[Autoplay] Decision: Advance to next track");
log::info!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let controller = controller_arc.lock().await;
// Prefer downloads that completed since the queue was built (e.g.
// preloaded upcoming tracks) over continuing to stream.
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
log::warn!("[Autoplay] Failed to refresh local sources: {}", e);
}
if let Err(e) = controller.next() {
log::error!("[Autoplay] Failed to advance to next track: {}", e);
// Emit PlaybackEnded event on error
@@ -235,3 +247,51 @@ pub async fn player_on_playback_ended(
Ok(())
}
// ===== HTML5 video state-report commands =====
//
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
// <video>), the real player lives outside the native backend, so the frontend
// HTML5 adapter reports DOM events back through these commands. The controller
// re-emits them through the same PlayerStatusEvent pipeline the native backends
// use, keeping the Rust controller the single source of truth and the frontend
// player store fed from one place (playerEvents.ts) in both modes.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
#[tauri::command]
#[specta::specta]
pub async fn player_report_state(
player: State<'_, PlayerStateWrapper>,
state: String,
media_id: Option<String>,
) -> Result<(), String> {
let controller = player.0.lock().await;
controller.report_html5_state(state, media_id);
Ok(())
}
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
/// these to roughly match the native backends' ~250ms cadence.
#[tauri::command]
#[specta::specta]
pub async fn player_report_position(
player: State<'_, PlayerStateWrapper>,
position: f64,
duration: f64,
) -> Result<(), String> {
let controller = player.0.lock().await;
controller.report_html5_position(position, duration);
Ok(())
}
/// Report that the HTML5 <video> finished loading and knows its duration.
#[tauri::command]
#[specta::specta]
pub async fn player_report_media_loaded(
player: State<'_, PlayerStateWrapper>,
duration: f64,
) -> Result<(), String> {
let controller = player.0.lock().await;
controller.report_html5_media_loaded(duration);
Ok(())
}
+155 -6
View File
@@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
@@ -51,7 +52,9 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
#[specta::specta]
pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>,
player: State<'_, crate::commands::player::PlayerStateWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String,
user_id: String,
access_token: String,
@@ -68,9 +71,18 @@ pub async fn repository_create(
})?;
debug!("[REPO] HTTP client created successfully");
// Create online repository
// Grab a connectivity reporter so the online repository's server outcomes
// drive the reachability state the UI observes (source of truth for the
// offline/online banner). See docs/architecture/07-connectivity.md.
let connectivity_reporter = {
let monitor = connectivity.0.lock().await;
monitor.reporter()
};
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token);
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
.with_connectivity(connectivity_reporter);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
@@ -104,6 +116,14 @@ pub async fn repository_create(
manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully");
// Give the player controller a repository for next-episode lookups. The
// Android playback-ended callback has no repository handle, so without
// this the episode autoplay countdown never triggers there.
if let Some(repo) = manager.0.get(&handle) {
let controller = player.0.lock().await;
controller.set_repository(repo);
}
Ok(handle)
}
@@ -168,6 +188,23 @@ pub async fn repository_get_item(
.map_err(|e| format!("{:?}", e))
}
/// Query the optional JRay plugin for the actors on screen at time `t`
/// (seconds) in an item. Returns an empty list when JRay isn't installed or
/// has no data for the item, so the caller can render nothing without error.
#[tauri::command]
#[specta::specta]
pub async fn repository_jray_actors_at(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
t: f64,
) -> Result<Vec<crate::repository::JRayActor>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_jray_actors(&item_id, t)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get latest items in a library
#[tauri::command]
#[specta::specta]
@@ -249,6 +286,21 @@ pub async fn repository_get_resume_movies(
.map_err(|e| format!("{:?}", e))
}
/// Get albums the user hasn't listened to recently ("rediscover")
#[tauri::command]
#[specta::specta]
pub async fn repository_get_rediscover_albums(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
#[specta::specta]
@@ -263,19 +315,73 @@ pub async fn repository_get_genres(
.map_err(|e| format!("{:?}", e))
}
/// Tauri event name carrying the merged (cache + server) search results.
pub const SEARCH_EVENT_NAME: &str = "search-event";
/// Payload for the deferred, merged search results pushed to the frontend.
///
/// `request_id` matches the value the frontend passed to `repository_search`,
/// letting it discard updates from queries that have since been superseded.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchUpdateEvent {
pub request_id: u32,
pub result: SearchResult,
}
/// Search for items
#[tauri::command]
#[specta::specta]
pub async fn repository_search(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
.search_cache_only(&query, options.clone())
.await
.map_err(|e| format!("{:?}", e))
.unwrap_or_else(|e| {
debug!("[Search] Cache search miss/timeout: {:?}", e);
SearchResult {
items: Vec::new(),
total_record_count: 0,
}
});
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
let repo_bg = repo.clone();
let cache_for_merge = cache_result.clone();
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
let event = SearchUpdateEvent {
request_id,
result: merged,
};
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
error!("[Search] Failed to emit search update: {}", e);
}
}
Err(e) => {
// Server failed — the cache results are already on screen, so
// just log. (Offline / unreachable server falls here.)
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
}
}
});
Ok(cache_result)
}
/// Get playback info for an item
@@ -330,6 +436,49 @@ pub async fn repository_get_audio_stream_url(
.map_err(|e| format!("{:?}", e))
}
/// Get Live TV channels (broadcast / IPTV) for browsing
#[tauri::command]
#[specta::specta]
pub async fn repository_get_live_tv_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_live_tv_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Get the root list of plugin "Channels"
#[tauri::command]
#[specta::specta]
pub async fn repository_get_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Open a live stream for a Live TV channel / live item
#[tauri::command]
#[specta::specta]
pub async fn repository_open_live_stream(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<LiveStreamInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.open_live_stream(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start
#[tauri::command]
#[specta::specta]
+356 -229
View File
@@ -1,15 +1,23 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript)
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
// Offline recovery probe interval.
// Reachability while online is driven by real repository traffic, so there is
// no online polling. While offline we probe quickly to detect the server
// returning even when no user traffic is flowing.
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
// Time-window debounce for declaring the server offline.
// A single dropped request must not trip the banner: we only flip to offline
// once network failures have persisted continuously for this window with no
// intervening success. Recovery (online) is instant on the first success.
const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5);
/// Connectivity status
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
@@ -45,210 +53,116 @@ struct ConnectivityChangeEvent {
is_reachable: bool,
}
/// Connectivity monitor for tracking server reachability
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
/// Shared reachability state and transition logic.
///
/// This is the single place that mutates reachability and emits events. It is
/// cheap to clone (all fields are `Arc`/`Option`) and is shared by:
/// - the `ConnectivityMonitor` (commands, offline recovery probe), and
/// - `OnlineRepository`, which reports the outcome of every server request.
///
/// Reachability is therefore driven by real traffic; the probe only fills the
/// gap while offline.
#[derive(Clone)]
pub struct ConnectivityReporter {
status: Arc<RwLock<ConnectivityStatus>>,
is_monitoring: Arc<AtomicBool>,
/// Timestamp of the first network failure in the current failure streak.
/// Used to debounce the transition to offline (see `OFFLINE_CONFIRM_WINDOW`).
first_failure_at: Arc<RwLock<Option<Instant>>>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
impl ConnectivityReporter {
fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
status,
first_failure_at: Arc::new(RwLock::new(None)),
app_handle,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
/// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity.
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
let mut server_url = self.server_url.write().await;
*server_url = Some(url.clone());
drop(server_url);
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
/// Test-only: force the reporter into the offline state without going through
/// the debounce, so other modules' tests can set up an "offline" precondition.
#[cfg(test)]
pub async fn mark_unreachable_for_test(&self) {
self.apply_probe_result(false, Some("forced offline (test)".to_string()))
.await;
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.status.read().await.clone()
/// Report that a real server request succeeded (or that the server answered
/// at all, e.g. with 401/404/5xx). The server is up — recover instantly.
pub async fn report_success(&self) {
*self.first_failure_at.write().await = None;
self.set_reachable(true, None).await;
}
/// Check if the Jellyfin server is reachable
pub async fn check_reachability(&self) -> bool {
// Mark as checking
{
let mut status = self.status.write().await;
status.is_checking = true;
/// Report that a real server request failed with a network-level error
/// (connection refused, timeout, DNS). Subject to the time-window debounce:
/// we only flip to offline once failures have persisted for
/// `OFFLINE_CONFIRM_WINDOW` with no intervening success.
pub async fn report_network_failure(&self, error: Option<String>) {
// If already offline, nothing to debounce.
if !self.status.read().await.is_server_reachable {
return;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
let mut status = self.status.write().await;
status.is_server_reachable = false;
status.connection_error = Some("No server URL configured".to_string());
status.is_checking = false;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
let now = Instant::now();
let streak_start = {
let mut first = self.first_failure_at.write().await;
*first.get_or_insert(now)
};
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW {
log::warn!(
"[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
OFFLINE_CONFIRM_WINDOW
);
self.set_reachable(false, error).await;
} else {
log::debug!(
"[ConnectivityMonitor] Network failure within debounce window; not yet offline"
);
}
}
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
/// Apply a deliberate reachability probe result (offline recovery probe or a
/// manual check). Unlike `report_network_failure`, a probe is an explicit
/// reachability test, so its result is applied immediately without debounce.
async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>) {
if is_reachable {
*self.first_failure_at.write().await = None;
}
self.set_reachable(is_reachable, error).await;
}
log::debug!(
"[ConnectivityMonitor] Ping result: {} (was: {})",
if is_reachable { "SUCCESS" } else { "FAILED" },
if was_reachable { "reachable" } else { "unreachable" }
);
// Update status
{
/// Core transition: update status and emit events only on an actual change.
async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
let was_reachable = {
let mut status = self.status.write().await;
let was = status.is_server_reachable;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
};
status.is_checking = false;
}
was
};
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
/// Mark server as reachable (called after successful API call)
pub async fn mark_reachable(&self) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = true;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = None;
drop(status);
if !was_reachable {
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
self.emit_connectivity_change(true).await;
self.emit_server_reconnected().await;
}
}
/// Mark server as unreachable (called after failed API call)
pub async fn mark_unreachable(&self, error: Option<String>) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = false;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
let error_msg = status.connection_error.clone().unwrap_or_default();
drop(status);
if was_reachable {
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
self.emit_connectivity_change(false).await;
}
}
/// Start monitoring connectivity with adaptive polling
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
// Perform immediate check before starting background task
// This ensures we get an accurate state right away instead of assuming offline
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
// Clone Arc references for the background task
let status = Arc::clone(&self.status);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let self_clone = Arc::new(ConnectivityMonitorHandle {
server_url,
http_client,
status,
app_handle: self.app_handle.clone(),
});
// Spawn background monitoring task
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
// Determine interval based on current reachability
let interval_ms = {
let status = self_clone.status.read().await;
if status.is_server_reachable {
AUTO_CHECK_INTERVAL_MS
} else {
RETRY_CHECK_INTERVAL_MS
}
};
// Wait for the interval
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
// Check if still monitoring
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Perform connectivity check
let _ = self_clone.check_reachability().await;
if is_reachable {
self.emit_server_reconnected().await;
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
}
/// Emit connectivity change event to frontend
@@ -275,74 +189,160 @@ impl ConnectivityMonitor {
}
}
/// Handle for the background monitoring task
struct ConnectivityMonitorHandle {
/// Connectivity monitor for tracking server reachability.
///
/// Reachability is driven primarily by real repository traffic via the shared
/// [`ConnectivityReporter`]. The monitor itself only runs an offline recovery
/// probe (see `start_monitoring`) and serves the connectivity Tauri commands.
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
app_handle: Option<AppHandle>,
reporter: ConnectivityReporter,
is_monitoring: Arc<AtomicBool>,
}
impl ConnectivityMonitorHandle {
async fn check_reachability(&self) -> bool {
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
let status = Arc::new(RwLock::new(ConnectivityStatus::default()));
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
reporter: ConnectivityReporter::new(status, None),
is_monitoring: Arc::new(AtomicBool::new(false)),
}
}
/// Set the Tauri app handle for event emission.
/// Must be called before the reporter is shared with the repository.
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.reporter.app_handle = Some(app_handle);
}
/// Get a cheap, cloneable reporter so the repository can feed server
/// outcomes into the same reachability state the UI observes.
pub fn reporter(&self) -> ConnectivityReporter {
self.reporter.clone()
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
*self.server_url.write().await = Some(url);
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.reporter.status.read().await.clone()
}
/// Deliberately probe the server's reachability (manual check / recovery probe).
/// The result is applied immediately (no debounce) since this is an explicit test.
pub async fn check_reachability(&self) -> bool {
{
let mut status = self.reporter.status.write().await;
status.is_checking = true;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
let Some(url) = server_url else {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
self.reporter
.apply_probe_result(false, Some("No server URL configured".to_string()))
.await;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
// Attempt to ping the server
let ping_url = format!("{}/System/Info/Public", url);
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {}",
if is_reachable { "SUCCESS" } else { "FAILED" }
);
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
self.reporter.apply_probe_result(is_reachable, None).await;
is_reachable
}
async fn emit_connectivity_change(&self, is_reachable: bool) {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
}
}
/// Mark server as reachable (called after successful API call / login)
pub async fn mark_reachable(&self) {
self.reporter.report_success().await;
}
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
}
/// Mark server as unreachable directly.
///
/// Used by deliberate signals (e.g. a failed login/connect) where the caller
/// knows the server is unreachable now. Repository traffic should prefer
/// `reporter().report_network_failure()` so the debounce applies.
pub async fn mark_unreachable(&self, error: Option<String>) {
self.reporter.apply_probe_result(false, error).await;
}
/// Start the offline recovery probe.
///
/// While **online**, reachability is kept fresh by real traffic, so the probe
/// idles. While **offline**, it polls `/System/Info/Public` every
/// `RETRY_CHECK_INTERVAL_MS` to detect the server returning even when no user
/// traffic is flowing.
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)");
// Perform an immediate check so startup reflects reality quickly.
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let reporter = self.reporter.clone();
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await;
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Only probe while offline — real traffic is the signal when online.
if reporter.status.read().await.is_server_reachable {
continue;
}
let Some(url) = server_url.read().await.clone() else {
continue;
};
let ping_url = format!("{}/System/Info/Public", url);
let is_reachable = http_client.ping(&ping_url).await;
// Probe only ever recovers us to online; a failed probe leaves us
// offline without re-emitting (no change).
if is_reachable {
reporter.apply_probe_result(true, None).await;
}
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
}
@@ -350,11 +350,22 @@ impl ConnectivityMonitorHandle {
mod tests {
use super::*;
/// Build a reporter backed by a fresh (optimistic) status, with no app handle.
/// Event emission is a no-op without a handle, which is exactly what we want
/// for unit-testing the reachability state transitions.
fn test_reporter() -> ConnectivityReporter {
ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None)
}
async fn is_reachable(reporter: &ConnectivityReporter) -> bool {
reporter.status.read().await.is_server_reachable
}
#[test]
fn test_intervals() {
// Verify intervals match TypeScript
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
// Offline recovery probe interval (online has no polling).
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
}
#[tokio::test]
@@ -366,4 +377,120 @@ mod tests {
assert!(status.connection_error.is_none());
assert!(!status.is_checking);
}
/// A single (or brief) network failure must NOT flip the app offline:
/// the time-window debounce keeps us online until the failure persists.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_single_network_failure_does_not_go_offline() {
let reporter = test_reporter();
assert!(is_reachable(&reporter).await, "starts online");
reporter
.report_network_failure(Some("timeout".to_string()))
.await;
assert!(
is_reachable(&reporter).await,
"one network failure within the debounce window stays online"
);
// But the failure streak is now being tracked.
assert!(reporter.first_failure_at.read().await.is_some());
}
/// Once failures persist past OFFLINE_CONFIRM_WINDOW, we flip offline.
/// We simulate elapsed time by backdating the streak start.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_sustained_network_failure_goes_offline() {
let reporter = test_reporter();
// First failure starts the streak.
reporter.report_network_failure(None).await;
assert!(is_reachable(&reporter).await);
// Backdate the streak start to before the window.
{
let mut first = reporter.first_failure_at.write().await;
*first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1));
}
// Next failure now exceeds the window → offline.
reporter
.report_network_failure(Some("connection refused".to_string()))
.await;
assert!(
!is_reachable(&reporter).await,
"sustained network failure flips to offline"
);
}
/// A success during a failure streak clears the streak and keeps us online —
/// recovery is instant and never trips the banner.
#[tokio::test]
async fn test_success_clears_failure_streak() {
let reporter = test_reporter();
reporter.report_network_failure(None).await;
assert!(reporter.first_failure_at.read().await.is_some());
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
assert!(
reporter.first_failure_at.read().await.is_none(),
"success resets the debounce streak"
);
}
/// First success after being offline recovers instantly (no debounce on the
/// way back up).
#[tokio::test]
async fn test_recovery_is_instant() {
let reporter = test_reporter();
// Force offline.
reporter.apply_probe_result(false, Some("down".to_string())).await;
assert!(!is_reachable(&reporter).await);
// A single success brings us straight back online.
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
let status = reporter.status.read().await;
assert!(status.connection_error.is_none());
}
/// Server-answered errors (401/404/5xx) are reported via report_success
/// by the repository, because the server is demonstrably reachable. This
/// test documents that contract: report_success means "server is up".
#[tokio::test]
async fn test_server_answered_error_counts_as_reachable() {
let reporter = test_reporter();
// Simulate being offline, then the server answers (even with an error).
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Repository maps Authentication/NotFound/Server errors to report_success.
reporter.report_success().await;
assert!(
is_reachable(&reporter).await,
"a server that answers (even with 4xx/5xx) is reachable"
);
}
/// report_network_failure is a no-op once already offline (nothing to debounce,
/// no duplicate events).
#[tokio::test]
async fn test_network_failure_noop_when_already_offline() {
let reporter = test_reporter();
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Should not panic or change state.
reporter.report_network_failure(Some("still down".to_string())).await;
assert!(!is_reachable(&reporter).await);
}
}
+98 -9
View File
@@ -284,22 +284,35 @@ impl JellyfinClient {
}
/// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek(
&self,
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SeekRequest {
seek_position_ticks: i64,
let url = format!(
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
self.config.server_url, session_id, position_ticks
);
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
let request = SeekRequest {
seek_position_ticks: position_ticks,
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
Ok(())
}
/// Send a full GeneralCommand to a remote session.
@@ -371,6 +384,82 @@ impl JellyfinClient {
let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
}
// --- JellyLMS multi-room sync groups -----------------------------------
//
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
// players ("zones") into synchronized multi-room sync groups. Players are
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
// stripping the `lms-` prefix off the session's device id (see
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
// with deviceId = "lms-{MacAddress}").
/// List current LMS sync groups.
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
self.get("/JellyLms/SyncGroups").await
}
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
pub async fn lms_create_sync_group(
&self,
master_mac: &str,
slave_macs: Vec<String>,
) -> Result<(), String> {
let payload = serde_json::json!({
"MasterMac": master_mac,
"SlaveMacs": slave_macs,
});
self.post("/JellyLms/SyncGroups", &payload).await
}
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
async fn delete(&self, endpoint: &str) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
Ok(())
}
}
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
///
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
/// follow it in lockstep.
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LmsSyncGroup {
#[serde(alias = "MasterMac")]
pub master_mac: String,
#[serde(default, alias = "MasterName")]
pub master_name: String,
#[serde(default, alias = "SlaveMacs")]
pub slave_macs: Vec<String>,
#[serde(default, alias = "SlaveNames")]
pub slave_names: Vec<String>,
}
/// Default value for supports_remote_control when missing from API
+248 -55
View File
@@ -28,7 +28,7 @@ use commands::{
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download,
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
@@ -40,6 +40,8 @@ use commands::{
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
player_get_autoplay_settings, player_set_autoplay_settings,
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
// HTML5 video state-report commands
player_report_state, player_report_position, player_report_media_loaded,
// Queue manipulation commands
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
player_remove_from_queue, player_move_in_queue, player_skip_to,
@@ -52,11 +54,13 @@ use commands::{
// Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status,
// Playback reporting commands
playback_reporter_init, playback_reporter_destroy,
@@ -94,10 +98,12 @@ use commands::{
storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_items,
repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
@@ -144,54 +150,126 @@ use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
///
/// Routes commands from the system media controls back to the PlayerController.
/// Routes commands from the system media controls to the right place depending on
/// playback mode: in local mode it drives the local `PlayerController`; in remote
/// (cast) mode it forwards transport commands to the remote Jellyfin session so
/// the lockscreen can control whatever is casting. Stop while casting requests a
/// disconnect back to local playback.
#[cfg(target_os = "android")]
struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>,
playback_mode: Arc<PlaybackModeManager>,
event_emitter: Arc<TauriEventEmitter>,
}
#[cfg(target_os = "android")]
impl MediaSessionHandler {
/// Forward a transport command to the active remote Jellyfin session.
///
/// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
/// threads without a Tokio context.
fn handle_remote_command(&self, command: &str, session_id: String) {
use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
// Stop while casting means "disconnect and resume locally". The frontend
// owns the remote->local transfer (it reloads the item locally), so we
// just signal intent.
if command == "stop" {
self.event_emitter
.emit(PlayerStatusEvent::RemoteDisconnectRequested);
return;
}
let jellyfin_client = {
let player = self.player.blocking_lock();
player.jellyfin_client()
};
let command = command.to_string();
tauri::async_runtime::spawn(async move {
let client = {
let guard = match jellyfin_client.lock() {
Ok(g) => g,
Err(e) => {
error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
return;
}
};
match guard.as_ref() {
Some(c) => c.clone(),
None => {
warn!("[MediaSession] No Jellyfin client for remote command");
return;
}
}
};
// Map lockscreen transport commands onto Jellyfin session commands.
let result = match command.as_str() {
"play" => client.send_session_command(session_id, "Unpause").await,
"pause" => client.send_session_command(session_id, "Pause").await,
"next" => client.send_session_command(session_id, "NextTrack").await,
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(seconds) => {
let ticks = (seconds * 10_000_000.0) as i64;
client.session_seek(session_id, ticks).await
}
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown remote command: {}", command);
Ok(())
}
};
if let Err(e) = result {
error!("[MediaSession] Remote command '{}' failed: {}", command, e);
}
});
}
/// Drive the local player for a transport command.
fn handle_local_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
let result = match command {
"play" => controller.play(),
"pause" => controller.pause(),
"next" => controller.next(),
"previous" => controller.previous(),
"stop" => controller.stop(),
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
Ok(pos) => controller.seek(pos),
Err(_) => {
warn!("[MediaSession] Bad seek command: {}", command);
Ok(())
}
},
_ => {
warn!("[MediaSession] Unknown command: {}", command);
Ok(())
}
};
if let Err(e) = result {
error!("[MediaSession] Command '{}' failed: {}", command, e);
}
}
}
#[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback
let controller = self.player.blocking_lock();
match command {
"play" => {
if let Err(e) = controller.play() {
error!("[MediaSession] Play failed: {}", e);
}
}
"pause" => {
if let Err(e) = controller.pause() {
error!("[MediaSession] Pause failed: {}", e);
}
}
"next" => {
if let Err(e) = controller.next() {
error!("[MediaSession] Next failed: {}", e);
}
}
"previous" => {
if let Err(e) = controller.previous() {
error!("[MediaSession] Previous failed: {}", e);
}
}
"stop" => {
if let Err(e) = controller.stop() {
error!("[MediaSession] Stop failed: {}", e);
}
}
cmd if cmd.starts_with("seek:") => {
if let Ok(pos) = cmd[5..].parse::<f64>() {
if let Err(e) = controller.seek(pos) {
error!("[MediaSession] Seek failed: {}", e);
}
}
}
_ => {
warn!("[MediaSession] Unknown command: {}", command);
match self.playback_mode.get_mode() {
playback_mode::PlaybackMode::Remote { session_id } => {
self.handle_remote_command(command, session_id);
}
_ => self.handle_local_command(command),
}
}
}
@@ -355,6 +433,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
// Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
@@ -397,6 +478,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_cancel_autoplay_countdown,
player_play_next_episode,
player_on_playback_ended,
player_report_state,
player_report_position,
player_report_media_loaded,
// Preload commands
player_preload_upcoming,
player_set_cache_config,
@@ -413,6 +497,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint,
sessions_poll_now,
@@ -423,6 +512,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
playback_mode_transfer_to_remote,
playback_mode_get_remote_status,
playback_mode_transfer_to_local,
playback_mode_set_transferring,
// Playback reporting commands
playback_reporter_init,
playback_reporter_destroy,
@@ -493,6 +583,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
mark_download_completed,
mark_download_failed,
start_download,
enqueue_download,
enqueue_video_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
get_smart_cache_stats,
@@ -547,16 +639,21 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_libraries,
repository_get_items,
repository_get_item,
repository_jray_actors_at,
repository_get_latest_items,
repository_get_resume_items,
repository_get_next_up_episodes,
repository_get_recently_played_audio,
repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres,
repository_search,
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
repository_report_playback_start,
repository_report_playback_progress,
repository_report_playback_stopped,
@@ -585,6 +682,81 @@ fn specta_builder() -> Builder<tauri::Wry> {
])
}
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
@@ -592,16 +764,29 @@ pub fn run() {
.filter_level(log::LevelFilter::Info)
.init();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init())
.setup(|app| {
.invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory
// Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -707,16 +892,11 @@ pub fn run() {
let player_arc = Arc::new(TokioMutex::new(player_controller));
// On Android, set up the MediaSession handler for lockscreen controls
// On Android, register the player controller for autoplay decisions.
// The MediaSession (lockscreen) handler is set up later, once the
// playback mode manager exists, so it can route to remote sessions.
#[cfg(target_os = "android")]
{
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
});
set_media_command_handler(handler);
// Register player controller for autoplay decisions
player::android::set_player_controller(player_arc.clone());
}
@@ -755,9 +935,19 @@ pub fn run() {
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
app.manage(session_poller_wrapper);
// On Android, set up remote volume handler for volume button intercept in remote mode
// On Android, set up the MediaSession (lockscreen) handler and the
// remote volume handler. Both need the playback mode manager so they
// can route to the active remote session while casting.
#[cfg(target_os = "android")]
{
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
let media_handler = Arc::new(MediaSessionHandler {
player: player_arc.clone(),
playback_mode: playback_mode_arc.clone(),
event_emitter: event_emitter.clone(),
});
set_media_command_handler(media_handler);
info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(),
@@ -833,15 +1023,18 @@ pub fn run() {
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
app.manage(repository_manager_wrapper);
// Initialize playback reporter wrapper (initially empty, set on login)
// Initialize playback reporter wrapper. This MUST share the same Arc
// the player controller and MPV progress loop report through (created
// above at `playback_reporter`), otherwise `playback_reporter_init`
// would populate a dead, parallel Option and no Start/Progress/Stopped
// would ever reach Jellyfin.
info!("[INIT] Initializing playback reporter wrapper...");
let playback_reporter_wrapper = PlaybackReporterWrapper(Arc::new(tokio::sync::Mutex::new(None)));
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
app.manage(playback_reporter_wrapper);
info!("[INIT] Application setup completed successfully");
Ok(())
})
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+154 -12
View File
@@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle,
}
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@@ -59,6 +81,19 @@ impl PlaybackModeManager {
self.is_transferring.load(Ordering::Relaxed)
}
/// Set the transferring flag directly.
///
/// The remote->local transfer is driven from the frontend in two steps
/// (`player_play_tracks` to start local playback, then
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
/// routing depends on this flag: while it's set, `player_play_tracks` plays
/// locally instead of casting back to the remote session. The frontend must
/// raise the flag *before* that first call and lower it when the sequence is
/// done (or aborts), so it can't be left stuck on.
pub fn set_transferring(&self, transferring: bool) {
self.is_transferring.store(transferring, Ordering::Relaxed);
}
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
@@ -159,7 +194,11 @@ impl PlaybackModeManager {
}
/// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id);
log::info!(
@@ -173,7 +212,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await;
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -181,12 +220,24 @@ impl PlaybackModeManager {
result
}
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
// position. Capture it so we can resume there and stop it afterwards.
let previous_remote_session = match self.get_mode() {
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
_ => None,
};
// Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = {
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await;
@@ -195,7 +246,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue();
let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
@@ -210,7 +260,19 @@ impl PlaybackModeManager {
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0);
// Prefer the frontend-supplied position when available. The backend
// position is unreliable as a transfer source: on Linux, *video* plays
// in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() is always 0; only the frontend knows the
// true position. We fall back to the live backend position (correct for
// Linux audio via MPV) when the frontend doesn't pass one.
let position = match position_override {
Some(p) => {
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
p
}
None => player.position(),
};
let context = queue.context().clone();
log::info!(
@@ -272,12 +334,48 @@ impl PlaybackModeManager {
}
};
// Calculate position in ticks
let start_position_ticks = if position_seconds > 0.5 {
Some((position_seconds * 10_000_000.0) as i64)
} else {
None
};
// Remote -> remote switch: take the current track and position from the
// session we're leaving, since the local player is idle and reports 0.
if let Some(ref prev_session_id) = previous_remote_session {
log::info!(
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
prev_session_id
);
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
position_seconds
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
now_id,
idx
);
current_index = idx;
} else {
log::warn!(
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
now_id,
current_index
);
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
}
}
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
@@ -392,6 +490,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string());
}
// Resume at the right position. We send StartPositionTicks in the play
// command above, but some Jellyfin client/server combinations ignore it
// and start from 0. Now that the track is confirmed loaded, issue an
// explicit seek as well (mirrors how the local resume path works). This
// is the reliable mechanism; StartPositionTicks is best-effort.
if let Some(ticks) = start_position_ticks {
log::info!(
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
ticks
);
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
// Non-fatal: the track is already playing, just not at the
// resume point. Log and continue rather than failing the transfer.
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
}
}
// Remote -> remote switch: stop the session we just left so we don't end
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
}
}
// Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{
@@ -577,6 +702,23 @@ mod tests {
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType};
+163 -2
View File
@@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{PlaybackReporter, EventThrottler};
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
@@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
}
/// Report throttled playback progress to Jellyfin from the Android position
/// callback. No-op until the reporter/throttler are wired (post-login) or when
/// not actively playing.
fn report_android_progress(position: f64) {
let item_id = match SHARED_STATE.get() {
Some(state) => {
let state = state.lock_safe();
if !state.state.is_playing() {
return;
}
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
Some(id) => id,
None => return,
}
}
None => return,
};
let throttler = match POSITION_THROTTLER.get() {
Some(t) => t,
None => return,
};
if !throttler.should_report(&item_id) {
return;
}
let reporter_arc = match PLAYBACK_REPORTER.get() {
Some(r) => r.clone(),
None => return,
};
let position_ticks = seconds_to_ticks(position);
let item_id_for_task = item_id.clone();
// The reporter is async; the JNI callback is sync. Spawn onto the Tokio
// runtime when present, otherwise a throwaway runtime on a new thread.
let spawn_report = move || async move {
let reporter_guard = reporter_arc.lock().await;
if let Some(reporter) = reporter_guard.as_ref() {
let operation = PlaybackOperation::Progress {
item_id: item_id_for_task.clone(),
position_ticks,
is_paused: false,
};
match reporter.report(operation, true).await {
Ok(_) => debug!("[Android] Reported progress for {}", item_id_for_task),
Err(e) => log::warn!("[Android] Failed to report progress: {}", e),
}
}
};
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(spawn_report());
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(spawn_report());
});
}
throttler.mark_reported(&item_id);
}
/// Called when player state changes.
@@ -678,7 +748,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
@@ -1112,6 +1187,92 @@ pub fn disable_remote_volume() -> Result<(), String> {
Ok(())
}
use crate::player::LockscreenMetadata;
/// Push now-playing metadata and playback state to the Android lockscreen.
///
/// Calls `JellyTauPlaybackService.updateMediaMetadata(...)`. The service must be
/// running (in remote mode it is started via [`enable_remote_volume`]); if it
/// isn't, this is a no-op rather than an error so it can be called freely on
/// every poll tick.
pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
let service_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let service_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&service_class_name.into())],
)
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let service_class = JClass::from(service_class_obj);
let service_obj = env
.call_static_method(
&service_class,
"getInstance",
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
&[],
)
.map_err(|e| format!("Failed to get service instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
// Service not running yet (e.g. nothing has played) - nothing to update.
if service_obj.is_null() {
return Ok(());
}
let title = env
.new_string(&meta.title)
.map_err(|e| format!("Failed to create title string: {}", e))?;
let artist = env
.new_string(&meta.artist)
.map_err(|e| format!("Failed to create artist string: {}", e))?;
// album is nullable on the Kotlin side; pass a real String or JObject::null().
let album_obj = match &meta.album {
Some(a) => env
.new_string(a)
.map_err(|e| format!("Failed to create album string: {}", e))?
.into(),
None => jni::objects::JObject::null(),
};
env.call_method(
&service_obj,
"updateMediaMetadata",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JJZ)V",
&[
JValue::Object(&title.into()),
JValue::Object(&artist.into()),
JValue::Object(&album_obj),
JValue::Long(meta.duration_ms),
JValue::Long(meta.position_ms),
JValue::Bool(meta.is_playing as u8),
],
)
.map_err(|e| format!("Failed to update lockscreen metadata: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
+24 -6
View File
@@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode};
@@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions.
///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback)
@@ -70,6 +78,11 @@ pub enum PlayerStatusEvent {
/// Remaining seconds (for time-based timer)
remaining_seconds: u32,
},
/// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
/// webview outside the backend's control — the frontend pauses it on this
/// event.
SleepTimerExpired,
/// Show next episode popup with countdown
ShowNextEpisodePopup {
/// Current episode that just finished
@@ -111,11 +124,14 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>,
},
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
/// frontend owns the two-step remote->local transfer (it must reload the
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
}
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend.
///
/// This abstraction allows backends to emit events without depending
@@ -141,7 +157,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
// Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e);
}
}
+558 -14
View File
@@ -46,6 +46,37 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
};
/// Metadata for the lockscreen / media notification.
///
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
/// the local ExoPlayer is idle and so can't supply now-playing info. The session
/// poller fills this in from the remote Jellyfin session and pushes it to the
/// notification so the lockscreen stays in sync while casting.
#[derive(Debug, Clone)]
pub struct LockscreenMetadata {
pub title: String,
pub artist: String,
pub album: Option<String>,
/// Track duration in milliseconds.
pub duration_ms: i64,
/// Current playback position in milliseconds.
pub position_ms: i64,
pub is_playing: bool,
}
/// Push now-playing metadata to the Android lockscreen. No-op off Android, so the
/// session poller can call it unconditionally and stay platform-agnostic.
pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::update_lockscreen_metadata(_meta);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};
@@ -134,9 +165,18 @@ impl PlayerController {
self.jellyfin_client.clone()
}
/// Configure the playback reporter for dual sync (local DB + server)
/// Will be called from initialization commands after login
#[allow(dead_code)]
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
*self.repository.lock_safe() = Some(repo);
}
/// Configure the playback reporter for dual sync (local DB + server).
/// Called from `player_configure_jellyfin` on login/restore/reauth.
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
let mut reporter_guard = self.playback_reporter.lock().await;
*reporter_guard = reporter;
@@ -217,6 +257,7 @@ impl PlayerController {
/// 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);
@@ -310,7 +351,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start
self.reset_autoplay_count();
@@ -323,11 +384,37 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
}
Ok(())
}
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
@@ -519,6 +606,13 @@ impl PlayerController {
self.queue.clone()
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
pub fn clear_queue(&self) {
self.queue.lock_safe().clear();
}
/// Toggle shuffle
pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle();
@@ -623,6 +717,10 @@ impl PlayerController {
mode: SleepTimerMode::Off,
remaining_seconds: 0,
});
// Tell the frontend playback must stop: HTML5 video
// (Linux) plays outside the backend, so stopping the
// backend below doesn't reach it.
emitter.emit(PlayerStatusEvent::SleepTimerExpired);
}
drop(timer);
@@ -679,6 +777,44 @@ impl PlayerController {
}
}
// ===== HTML5 video report methods =====
//
// On platforms where video is rendered in the webview (Linux WebKitGTK
// HTML5 <video>), the real player lives outside the native backend, so it
// cannot emit PlayerStatusEvents itself. The frontend HTML5 adapter reports
// DOM events here, and these methods re-emit them through the SAME event
// pipeline the native backends use. This keeps the frontend's player store
// fed from one place (playerEvents.ts) in both native and HTML5 modes, so
// the Rust controller stays the single source of truth for player state.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped).
///
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
}
}
/// Report an HTML5 <video> position tick.
///
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
pub fn report_html5_position(&self, position: f64, duration: f64) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
}
}
/// Report that the HTML5 <video> element finished loading and knows its
/// duration. Mirrors the native `MediaLoaded` event.
pub fn report_html5_media_loaded(&self, duration: f64) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
}
// ===== Autoplay Methods =====
/// Get autoplay settings
@@ -795,9 +931,17 @@ impl PlayerController {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
// Degrade lookup failures to Stop: playback already ended, and
// surfacing an error here just kills autoplay silently upstream.
match self.fetch_next_episode_for_item(jellyfin_id, repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", jellyfin_id, e);
None
}
}
} else {
debug!("[PlayerController] No repository available for audio-path episode lookup");
warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
None
};
if let Some(next_ep) = next_ep_result {
@@ -852,7 +996,7 @@ impl PlayerController {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
}
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
// Check sleep timer state
let timer_mode = {
@@ -885,8 +1029,17 @@ impl PlayerController {
_ => {}
}
// Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
// Fetch next episode for the video that just ended. Degrade lookup
// failures to Stop: playback already ended, and propagating an error
// here just kills autoplay silently upstream.
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
Ok(next) => next,
Err(e) => {
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
None
}
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count();
@@ -936,7 +1089,7 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
log::info!("[PlayerController] Current item has no season_id, cannot find next episode");
return Ok(None);
}
};
@@ -958,19 +1111,19 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
// Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1];
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
return Ok(Some((current_repo_item, next.clone())));
} else {
debug!("[PlayerController] Current episode is the last in the season");
log::info!("[PlayerController] Current episode is the last in the season");
}
} else {
debug!("[PlayerController] Current episode not found in season episodes");
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
}
Ok(None)
@@ -1024,6 +1177,83 @@ impl Default for PlayerController {
mod tests {
use super::*;
/// Test emitter that captures events for asserting the HTML5 report methods
/// re-emit through the normal PlayerStatusEvent pipeline.
struct CapturingEmitter {
events: std::sync::Mutex<Vec<PlayerStatusEvent>>,
}
impl CapturingEmitter {
fn new() -> Self {
Self {
events: std::sync::Mutex::new(Vec::new()),
}
}
fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone()
}
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock_safe().push(event);
}
}
#[test]
fn test_report_html5_state_emits_state_changed() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::StateChanged { state, media_id } => {
assert_eq!(state, "playing");
assert_eq!(media_id.as_deref(), Some("item-1"));
}
other => panic!("expected StateChanged, got {:?}", other),
}
}
#[test]
fn test_report_html5_position_emits_position_update() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_position(12.5, 300.0);
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::PositionUpdate { position, duration } => {
assert_eq!(*position, 12.5);
assert_eq!(*duration, 300.0);
}
other => panic!("expected PositionUpdate, got {:?}", other),
}
}
#[test]
fn test_report_html5_media_loaded_emits_media_loaded() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_media_loaded(420.0);
let events = emitter.events();
assert_eq!(events.len(), 1);
match &events[0] {
PlayerStatusEvent::MediaLoaded { duration } => assert_eq!(*duration, 420.0),
other => panic!("expected MediaLoaded, got {:?}", other),
}
}
#[test]
fn test_controller_volume_default() {
let controller = PlayerController::default();
@@ -1332,6 +1562,44 @@ mod tests {
}
}
/// Resuming a queue at a position seeks the starting track immediately.
/// Regression guard for taking over a remote session: the local player must
/// pick up where the remote left off, not restart from 0.
#[test]
fn test_play_queue_from_resumes_at_position() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
}
/// A None / near-zero start position starts the track from the beginning.
#[test]
fn test_play_queue_from_without_position_starts_at_zero() {
let controller = PlayerController::default();
controller
.play_queue_from(create_test_items(2), 0, None)
.unwrap();
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
controller
.play_queue_from(create_test_items(2), 0, Some(0.2))
.unwrap();
assert_eq!(
controller.position(),
0.0,
"Sub-threshold resume position is ignored (starts at 0)"
);
}
#[test]
fn test_seek_to_zero() {
let controller = PlayerController::default();
@@ -1644,4 +1912,280 @@ mod tests {
let reason = controller.take_end_reason();
assert!(reason.is_none(), "take_end_reason should clear the state");
}
// ===== Next-episode autoplay decision tests =====
use crate::repository::types as repo_types;
/// Mock repository serving a single season of episodes for next-episode
/// lookup tests. Only `get_item` and `get_items` are used by
/// `fetch_next_episode_for_item`; everything else is unreachable.
struct MockEpisodeRepo {
episodes: Vec<repo_types::MediaItem>,
}
impl MockEpisodeRepo {
fn season(count: usize) -> Self {
let episodes = (1..=count)
.map(|i| {
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
item.name = format!("Episode {}", i);
item
})
.collect();
Self { episodes }
}
}
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
repo_types::MediaItem {
id: id.to_string(),
name: format!("Episode {}", index),
item_type: "Episode".to_string(),
is_folder: false,
server_id: "server".to_string(),
parent_id: Some("season1".to_string()),
library_id: None,
overview: None,
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
backdrop_image_tags: None,
parent_backdrop_image_tags: None,
album_id: None,
album_name: None,
album_artist: None,
artists: None,
artist_items: None,
index_number: Some(index),
series_id: Some("series1".to_string()),
series_name: Some("Test Series".to_string()),
season_id: Some("season1".to_string()),
season_name: Some("Season 1".to_string()),
parent_index_number: Some(1),
user_data: None,
media_streams: None,
media_sources: None,
people: None,
}
}
#[async_trait::async_trait]
impl crate::repository::MediaRepository for MockEpisodeRepo {
async fn get_libraries(&self) -> Result<Vec<repo_types::Library>, repo_types::RepoError> {
unimplemented!()
}
async fn get_items(
&self,
parent_id: &str,
_options: Option<repo_types::GetItemsOptions>,
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
assert_eq!(parent_id, "season1", "episode lookup must query the season");
Ok(repo_types::SearchResult {
items: self.episodes.clone(),
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
.cloned()
.ok_or(repo_types::RepoError::NotFound {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_items(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_next_up_episodes(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_recently_played_audio(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_rediscover_albums(&self, _: Option<&str>, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_resume_movies(&self, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_genres(&self, _: Option<&str>) -> Result<Vec<repo_types::Genre>, repo_types::RepoError> {
unimplemented!()
}
async fn search(&self, _: &str, _: Option<repo_types::SearchOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_playback_info(&self, _: &str) -> Result<repo_types::PlaybackInfo, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _: &str) -> Result<repo_types::LiveStreamInfo, repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_progress(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn report_playback_stopped(&self, _: &str, _: i64) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
fn get_image_url(&self, _: &str, _: repo_types::ImageType, _: Option<repo_types::ImageOptions>) -> String {
unimplemented!()
}
fn get_subtitle_url(&self, _: &str, _: &str, _: i32, _: &str) -> String {
unimplemented!()
}
fn get_video_download_url(&self, _: &str, _: &str, _: Option<&str>) -> String {
unimplemented!()
}
async fn mark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
unimplemented!()
}
async fn get_items_by_person(&self, _: &str, _: Option<repo_types::GetItemsOptions>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn get_similar_items(&self, _: &str, _: Option<usize>) -> Result<repo_types::SearchResult, repo_types::RepoError> {
unimplemented!()
}
async fn create_playlist(&self, _: &str, _: &[String]) -> Result<repo_types::PlaylistCreatedResult, repo_types::RepoError> {
unimplemented!()
}
async fn delete_playlist(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn rename_playlist(&self, _: &str, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_playlist_items(&self, _: &str) -> Result<Vec<repo_types::PlaylistEntry>, repo_types::RepoError> {
unimplemented!()
}
async fn add_to_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn remove_from_playlist(&self, _: &str, _: &[String]) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn move_playlist_item(&self, _: &str, _: &str, _: u32) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
/// Video (HTML5/Linux) path: ending mid-season must produce the
/// next-episode popup with auto-advance.
#[tokio::test]
async fn test_video_playback_ended_offers_next_episode() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep2", repo)
.await
.expect("decision should succeed");
match decision {
AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
auto_advance,
..
} => {
assert_eq!(current_episode.id, "ep2");
assert_eq!(next_episode.id, "ep3");
assert!(auto_advance, "default settings should auto-advance");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Last episode of the season: no popup, stop.
#[tokio::test]
async fn test_video_playback_ended_last_episode_stops() {
let controller = PlayerController::default();
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::season(3));
let decision = controller
.on_video_playback_ended("ep3", repo)
.await
.expect("decision should succeed");
assert!(matches!(decision, AutoplayDecision::Stop));
}
/// Android/ExoPlayer path: `on_playback_ended` has no per-call repository,
/// so the controller-level repository (wired up in `repository_create`)
/// must be used for the next-episode lookup. Regression test for episode
/// autoplay never triggering on Android because no repository was set.
#[tokio::test]
async fn test_playback_ended_uses_controller_repository_for_episodes() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Queue holds the episode that just finished playing
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep2");
}
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
}
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
async fn test_playback_ended_without_repository_stops() {
let controller = PlayerController::default();
let episode = MediaItem {
media_type: MediaType::Video,
source: MediaSource::Remote {
stream_url: "http://example.com/ep1.mkv".to_string(),
jellyfin_item_id: "ep1".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
assert!(matches!(decision, AutoplayDecision::Stop));
}
}
+35
View File
@@ -62,6 +62,14 @@ impl QueueManager {
&self.items
}
/// Mutable access to queue items.
///
/// Used to re-point streaming entries at a completed local download
/// without disturbing queue order, shuffle state, or the current index.
pub fn items_mut(&mut self) -> &mut [MediaItem] {
&mut self.items
}
/// Get the current item index
pub fn current_index(&self) -> Option<usize> {
self.current_index
@@ -122,6 +130,18 @@ impl QueueManager {
self.context = context;
}
/// Clear the queue entirely, returning it to the empty state.
///
/// Used when playback genuinely stops (sleep timer fires, or the queue ends
/// with repeat off) so the frontend's `currentQueueItem` becomes null and
/// the mini player hides. History and shuffle order are reset too.
pub fn clear(&mut self) {
self.items.clear();
self.current_index = None;
self.history.clear();
self.shuffle_order.clear();
}
/// Add items to the queue
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
if items.is_empty() {
@@ -559,6 +579,21 @@ mod tests {
assert_eq!(queue.current().unwrap().id, "item_0");
}
/// Test clearing the queue returns it to the empty state so the frontend
/// hides the mini player on a genuine stop.
#[test]
fn test_clear() {
let mut queue = QueueManager::new();
queue.set_queue(create_test_items(3), 1);
assert_eq!(queue.current_index(), Some(1));
queue.clear();
assert_eq!(queue.items().len(), 0);
assert_eq!(queue.current_index(), None);
assert!(queue.current().is_none());
}
/// Test next track navigation
///
/// @req-test: UR-005 - Control media playback (skip to next track)
+6 -1
View File
@@ -61,7 +61,12 @@ pub enum PlayerState {
}
impl PlayerState {
/// Get the current playback position if available
/// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> {
match self {
PlayerState::Playing { position, .. } => Some(*position),
+230
View File
@@ -45,6 +45,12 @@ impl HybridRepository {
self.online.download_bytes(url).await
}
/// Query the JRay plugin for actors on screen at time `t`. Online-only
/// (the plugin lives on the Jellyfin server); empty when JRay isn't present.
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<super::JRayActor>, RepoError> {
self.online.get_jray_actors(item_id, t).await
}
/// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths.
pub async fn get_video_stream_url(
@@ -57,6 +63,81 @@ impl HybridRepository {
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.
///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
@@ -274,6 +355,27 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
let parent_id_owned = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_owned.clone();
let cache_future = self.cache_with_timeout(async move {
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
});
let server_future = async move {
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
};
self.parallel_race(cache_future, server_future).await
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -319,6 +421,21 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels require server communication - delegate to online repository
self.online.get_channels().await
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Opening a live stream requires server communication - delegate to online
self.online.open_live_stream(item_id).await
}
async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
// Playback reporting goes directly to server
self.online.report_playback_start(item_id, position_ticks).await
@@ -602,6 +719,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -618,6 +743,18 @@ mod tests {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!()
}
@@ -759,6 +896,14 @@ mod tests {
unimplemented!()
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!()
}
@@ -775,6 +920,18 @@ mod tests {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
unimplemented!()
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
unimplemented!()
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!()
}
@@ -864,6 +1021,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Movie".to_string(),
is_folder: false,
server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()),
library_id: Some("library-456".to_string()),
@@ -871,6 +1029,7 @@ mod tests {
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
runtime_ticks: Some(7200000000),
production_year: Some(2024),
premiere_date: None,
community_rating: Some(8.5),
official_rating: Some("PG-13".to_string()),
primary_image_tag: Some("image-tag-123".to_string()),
@@ -1058,4 +1217,75 @@ mod tests {
};
assert!(result_with_items.has_content(), "Result with items should have content");
}
#[test]
fn test_merge_search_local_first_then_server_appended() {
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("c", "Server C"),
create_test_item("d", "Server D"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
// Local items first (in order), then server-only items appended.
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
assert_eq!(merged.total_record_count, 4);
}
#[test]
fn test_merge_search_dedupes_with_server_winning() {
// "b" appears in both. Server metadata should win, but the item keeps
// its earlier (local) position and is not duplicated.
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("b", "Server B (fresher)"),
create_test_item("c", "Server C"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
assert_eq!(merged.total_record_count, 3);
}
#[test]
fn test_merge_search_handles_empty_sides() {
let only_server = HybridRepository::merge_search_results(
SearchResult { items: vec![], total_record_count: 0 },
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
);
assert_eq!(only_server.items.len(), 1);
assert_eq!(only_server.items[0].id, "x");
let only_cache = HybridRepository::merge_search_results(
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
SearchResult { items: vec![], total_record_count: 0 },
);
assert_eq!(only_cache.items.len(), 1);
assert_eq!(only_cache.items[0].id, "y");
}
}
+23 -1
View File
@@ -4,7 +4,7 @@ pub mod offline;
pub mod hybrid;
pub use types::*;
pub use online::OnlineRepository;
pub use online::{OnlineRepository, JRayActor};
pub use offline::OfflineRepository;
pub use hybrid::HybridRepository;
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get albums the user has played, but not recently ("rediscover" / haven't
/// listened to in a while). Returns albums sorted by least-recently played
/// first, optionally restricted to a parent library.
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
@@ -108,6 +117,19 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
/// Get the root list of plugin "Channels" (Jellyfin Channels feature).
/// Drill-down into a channel reuses `get_items(channel_id, ...)`.
async fn get_channels(&self) -> Result<SearchResult, RepoError>;
/// Open a live stream (Live TV channel or live channel item) for playback.
///
/// Returns the server transcoding URL plus identifiers needed to manage the
/// stream. Required before a live channel can be played over HLS.
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError>;
/// Report playback start
///
/// @req: UR-025 - Sync watch history and progress back to Jellyfin
+110 -41
View File
@@ -29,6 +29,7 @@ impl OfflineRepository {
id: item.id.clone(),
name: item.name,
item_type: item.item_type,
is_folder: item.is_folder,
server_id: item.server_id,
parent_id: item.parent_id,
library_id: item.library_id,
@@ -38,6 +39,7 @@ impl OfflineRepository {
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
runtime_ticks: item.runtime_ticks,
production_year: item.production_year,
premiere_date: item.premiere_date,
community_rating: item.community_rating,
official_rating: item.official_rating,
primary_image_tag: item.primary_image_tag,
@@ -92,6 +94,7 @@ struct CachedItem {
id: String,
name: String,
item_type: String,
is_folder: bool,
server_id: String,
parent_id: Option<String>,
library_id: Option<String>,
@@ -99,6 +102,7 @@ struct CachedItem {
genres: Option<String>,
runtime_ticks: Option<i64>,
production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>,
official_rating: Option<String>,
primary_image_tag: Option<String>,
@@ -143,6 +147,9 @@ fn row_to_cached_item(row: &rusqlite::Row) -> rusqlite::Result<CachedItem> {
season_id: row.get(20)?,
season_name: row.get(21)?,
parent_index_number: row.get(22)?,
// Appended as the final columns in every SELECT that maps through this fn.
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
premiere_date: row.get(24)?,
})
}
@@ -230,24 +237,24 @@ impl OfflineRepository {
let query = Query::with_params(
"INSERT OR REPLACE INTO items (
id, server_id, library_id, parent_id,
name, item_type, overview,
name, item_type, is_folder, overview,
genres, series_id, series_name,
season_id, season_name, index_number, parent_index_number,
album_id, album_name, album_artist, artists,
production_year, runtime_ticks,
production_year, premiere_date, runtime_ticks,
primary_image_tag, backdrop_image_tags,
community_rating, official_rating,
synced_at
) VALUES (
?1, ?2, ?3, ?4,
?5, ?6, ?7,
?8, ?9, ?10,
?11, ?12, ?13, ?14,
?15, ?16, ?17, ?18,
?19, ?20,
?21, ?22,
?5, ?6, ?7, ?8,
?9, ?10, ?11,
?12, ?13, ?14, ?15,
?16, ?17, ?18, ?19,
?20, ?21, ?22,
?23, ?24,
?25
?25, ?26,
?27
)",
vec![
QueryParam::String(item.id.clone()),
@@ -261,6 +268,7 @@ impl OfflineRepository {
},
QueryParam::String(item.name.clone()),
QueryParam::String(item.item_type.clone()),
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
match &item.overview {
Some(o) => QueryParam::String(o.clone()),
None => QueryParam::Null,
@@ -313,6 +321,10 @@ impl OfflineRepository {
Some(y) => QueryParam::Int(y),
None => QueryParam::Null,
},
match &item.premiere_date {
Some(d) => QueryParam::String(d.clone()),
None => QueryParam::Null,
},
match item.runtime_ticks {
Some(r) => QueryParam::Int64(r),
None => QueryParam::Null,
@@ -441,6 +453,13 @@ impl MediaRepository for OfflineRepository {
let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
let start_index = opts.start_index.unwrap_or(0);
// SortBy=Random is the only sort the landing pages rely on offline (the
// hero "surprise" pool); everything else keeps the stable name order.
let order_by = match opts.sort_by.as_deref() {
Some("Random") => "RANDOM()",
_ => "i.sort_name ASC, i.name ASC",
};
// Build type filter for optional filtering
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
if !include_item_types.is_empty() {
@@ -491,13 +510,13 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
FROM items i
i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
INNER JOIN available_items ai ON i.id = ai.id
WHERE i.server_id = ? AND i.parent_id = ?{}
ORDER BY i.sort_name ASC, i.name ASC
ORDER BY {}
LIMIT {} OFFSET {}",
type_filter, limit, start_index
type_filter, order_by, limit, start_index
);
let query = Query::with_params(
@@ -556,8 +575,8 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
FROM items i
i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.id = ?",
vec![QueryParam::String(item_id.to_string())],
@@ -601,8 +620,8 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
i.parent_index_number
FROM items i
i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ?
ORDER BY i.synced_at DESC
@@ -639,8 +658,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM items i
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
@@ -664,8 +683,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM items i
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
WHERE i.server_id = ? AND ud.user_id = ?
@@ -752,8 +771,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM ranked_plays rp
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM ranked_plays rp
JOIN items i ON rp.display_id = i.id
INNER JOIN downloaded_items di ON i.id = di.id
ORDER BY rp.most_recent_play DESC",
@@ -778,6 +797,17 @@ impl MediaRepository for OfflineRepository {
Ok(items)
}
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// "Rediscover" is a discovery feature over the full server library.
// Offline only holds downloaded items, so there is nothing meaningful
// to surface here; the hybrid repo serves this from the server instead.
Ok(Vec::new())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
@@ -789,8 +819,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM items i
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id
WHERE i.server_id = ? AND ud.user_id = ? AND i.item_type = 'Movie'
@@ -819,41 +849,57 @@ impl MediaRepository for OfflineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items
let mut genre_set = std::collections::HashSet::new();
// Derive genres from cached albums, tallying how many albums carry each
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
// power the music landing) and read every matching row — NOT DISTINCT —
// so the per-genre counts are real. Genres are stored as a JSON array
// string per item.
let (sql, params) = if let Some(pid) = parent_id {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL",
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![
QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()),
]
],
)
} else {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())]
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
let query = Query::with_params(sql, params);
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0))
let genres_rows: Vec<String> = self
.db_service
.query_many(query, |row| row.get(0))
.await
.map_err(|e| RepoError::Database { message: e })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
genre_set.insert(genre);
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = genre_set
let genres = counts
.into_iter()
.map(|name| Genre { id: name.clone(), name })
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres)
@@ -907,8 +953,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM items i
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND items_fts MATCH ?{}
@@ -953,6 +999,21 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Plugin channels are inherently online-only.
Err(RepoError::Offline)
}
async fn open_live_stream(&self, _item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live streams cannot be opened offline.
Err(RepoError::Offline)
}
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
// Cannot report to server while offline
Err(RepoError::Offline)
@@ -1044,6 +1105,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0,
name: person_data.1,
item_type: "Person".to_string(),
is_folder: false,
server_id: self.server_id.clone(),
parent_id: None,
library_id: None,
@@ -1051,6 +1113,7 @@ impl MediaRepository for OfflineRepository {
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: person_data.3,
@@ -1104,8 +1167,8 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number
FROM items i
i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i
JOIN item_people ip ON i.id = ip.item_id
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND ip.person_id = ?
@@ -1215,7 +1278,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
i.parent_index_number \
i.parent_index_number, i.is_folder, i.premiere_date \
FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \
@@ -1253,6 +1316,8 @@ impl MediaRepository for OfflineRepository {
season_id: row.get(21)?,
season_name: row.get(22)?,
parent_index_number: row.get(23)?,
is_folder: row.get::<_, Option<i64>>(24)?.unwrap_or(0) != 0,
premiere_date: row.get(25)?,
};
Ok((entry_id.to_string(), cached))
})
@@ -1419,10 +1484,12 @@ mod tests {
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL,
item_type TEXT NOT NULL,
is_folder INTEGER DEFAULT 0,
overview TEXT,
genres TEXT,
runtime_ticks INTEGER,
production_year INTEGER,
premiere_date TEXT,
community_rating REAL,
official_rating TEXT,
primary_image_tag TEXT,
@@ -1491,6 +1558,7 @@ mod tests {
id: id.to_string(),
name: name.to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()),
library_id: None,
@@ -1498,6 +1566,7 @@ mod tests {
genres: None,
runtime_ticks: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
primary_image_tag: None,
+496 -30
View File
@@ -7,15 +7,44 @@ use log::{debug, error, info};
use log::warn;
use serde::{Deserialize, Serialize};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*};
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
///
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct JRayActor {
pub name: String,
#[serde(default)]
pub imdb_id: String,
#[serde(default)]
pub tmdb_id: String,
#[serde(default)]
pub jellyfin_id: String,
}
/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
#[derive(Debug, Clone, Deserialize)]
struct JRayContext {
#[serde(default)]
actors: Vec<JRayActor>,
}
/// Online repository - fetches data from Jellyfin server via HTTP
pub struct OnlineRepository {
http_client: Arc<HttpClient>,
server_url: String,
user_id: String,
access_token: String,
/// Reports the outcome of every server request to the connectivity monitor.
/// This is the source of truth for the offline/online banner. `None` in
/// tests / contexts where connectivity tracking isn't wired up.
connectivity: Option<ConnectivityReporter>,
}
impl OnlineRepository {
@@ -30,6 +59,44 @@ impl OnlineRepository {
server_url,
user_id,
access_token,
connectivity: None,
}
}
/// Attach a connectivity reporter so server outcomes drive the reachability
/// state observed by the UI. See `report_outcome`.
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
self.connectivity = Some(reporter);
self
}
/// Feed a request outcome into the connectivity monitor.
///
/// Classification (matches docs/architecture/07-connectivity.md):
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
/// so it is reachable → `report_success` (instant recovery).
/// - `Network` → connection-level failure → `report_network_failure`
/// (subject to the time-window debounce before going offline).
/// - `Database` → not a server signal → ignored.
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
let Some(reporter) = &self.connectivity else {
return;
};
match result {
Ok(_)
| Err(RepoError::Authentication { .. })
| Err(RepoError::NotFound { .. })
| Err(RepoError::Server { .. }) => {
reporter.report_success().await;
}
Err(RepoError::Network { message }) => {
reporter.report_network_failure(Some(message.clone())).await;
}
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
// Local-side errors (cache failure / already-offline) — not a
// statement about the server's reachability, so ignore them.
}
}
}
@@ -61,8 +128,28 @@ impl OnlineRepository {
.map_err(|e| format!("Failed to read bytes: {}", e))
}
/// Query the JRay plugin for the actors on screen at time `t` (seconds) in
/// the given item. Returns an empty list when the plugin isn't installed or
/// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
/// and "nobody on screen" identically. Other failures propagate.
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<JRayActor>, RepoError> {
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
match self.get_json::<JRayContext>(&endpoint).await {
Ok(context) => Ok(context.actors),
// No plugin / no truth data for this item — not an error to the user.
Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
Err(e) => Err(e),
}
}
/// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await;
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url)
@@ -110,6 +197,12 @@ impl OnlineRepository {
/// Make authenticated POST request
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let result = self.post_json_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url)
@@ -145,6 +238,16 @@ impl OnlineRepository {
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
let result = self.post_json_response_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
@@ -193,9 +296,16 @@ impl OnlineRepository {
})
}
/// Get a video stream URL for playback with optional seeking support.
/// For direct streams, uses /Videos/{id}/stream.mp4 with transcoding to support seeking.
/// For transcoded streams (HEVC/10-bit), includes StartTimeTicks parameter.
/// Get a video stream URL for playback at an arbitrary position (resume,
/// transcoded seeking, audio-track switching).
///
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
pub async fn get_video_stream_url(
&self,
item_id: &str,
@@ -210,12 +320,11 @@ impl OnlineRepository {
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build URL with progressive MP4 transcoding for seeking support
// This works for both direct streams and HEVC content
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("Container", "mp4".to_string()),
("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index),
@@ -223,6 +332,9 @@ impl OnlineRepository {
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
];
if let Some(source_id) = media_source_id {
@@ -241,7 +353,7 @@ impl OnlineRepository {
.join("&");
let url = format!(
"{}/Videos/{}/stream.mp4?{}",
"{}/Videos/{}/master.m3u8?{}",
self.server_url, item_id, query
);
@@ -289,10 +401,13 @@ struct JellyfinItem {
name: String,
#[serde(rename = "Type")]
item_type: String,
#[serde(default)]
is_folder: bool,
parent_id: Option<String>,
overview: Option<String>,
genres: Option<Vec<String>>,
production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>,
official_rating: Option<String>,
run_time_ticks: Option<i64>,
@@ -376,12 +491,14 @@ impl JellyfinItem {
id: self.id,
name: self.name,
item_type: self.item_type,
is_folder: self.is_folder,
server_id,
parent_id: self.parent_id,
library_id: None, // Not provided by Jellyfin API directly
overview: self.overview,
genres: self.genres,
production_year: self.production_year,
premiere_date: self.premiere_date,
community_rating: self.community_rating,
official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks,
@@ -494,10 +611,22 @@ impl MediaRepository for OnlineRepository {
if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive));
}
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
}
// Request image fields for list views (People only needed in get_item detail view)
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -512,7 +641,7 @@ impl MediaRepository for OnlineRepository {
}
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources", self.user_id, item_id);
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.to_media_item(self.user_id.clone());
@@ -642,12 +771,14 @@ impl MediaRepository for OnlineRepository {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
@@ -685,6 +816,32 @@ impl MediaRepository for OnlineRepository {
Ok(final_result)
}
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
@@ -701,7 +858,12 @@ impl MediaRepository for OnlineRepository {
}
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id);
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
@@ -718,17 +880,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre {
id: String,
name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
}
let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response
let genres: Vec<Genre> = response
.items
.into_iter()
.map(|g| Genre {
id: g.id,
name: g.name,
album_count: g.album_count.or(g.child_count),
})
.collect())
.collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
}
async fn search(
@@ -737,19 +923,30 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id, query, limit
self.user_id,
urlencoding::encode(query),
limit
);
if let Some(opts) = options {
if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
}
}
// Request image fields for list views (People only needed in get_item detail view)
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags");
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -987,6 +1184,105 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
let endpoint = format!(
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
self.user_id
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect())
}
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Root list of plugin "Channels". Drill-down into a channel folder reuses
// get_items(channel_id, ...).
let endpoint = format!("/Channels?UserId={}", self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let total = response.total_record_count;
let items = response
.items
.into_iter()
.map(|item| item.to_media_item(self.server_url.clone()))
.collect();
Ok(SearchResult {
items,
total_record_count: total,
})
}
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
// Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
// server opens the live stream and returns a ready-to-play transcoding URL.
// We send a minimal request; the server applies its own defaults for live.
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamRequest {
user_id: String,
#[serde(rename = "AutoOpenLiveStream")]
auto_open_live_stream: bool,
is_playback: bool,
max_streaming_bitrate: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct OpenLiveStreamResponse {
#[serde(default)]
media_sources: Vec<LiveMediaSource>,
play_session_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct LiveMediaSource {
id: String,
transcoding_url: Option<String>,
live_stream_id: Option<String>,
}
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
let request = OpenLiveStreamRequest {
user_id: self.user_id.clone(),
auto_open_live_stream: true,
is_playback: true,
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
let stream_url = match source.transcoding_url {
Some(url) => format!("{}{}", self.server_url, url),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
self.server_url,
item_id,
self.access_token,
source.id,
source.live_stream_id.clone().unwrap_or_default(),
),
};
Ok(LiveStreamInfo {
stream_url,
play_session_id: response.play_session_id,
live_stream_id: source.live_stream_id,
media_source_id: Some(source.id),
})
}
async fn report_playback_start(
&self,
item_id: &str,
@@ -1147,23 +1443,29 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let result = async {
let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
.await;
Ok(())
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
@@ -1388,6 +1690,91 @@ mod tests {
)
}
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
fn create_test_repository_with_connectivity(
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
.expect("Failed to create HTTP client for monitor");
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
let reporter = monitor.reporter();
let repo = create_test_repository().with_connectivity(reporter.clone());
(repo, reporter)
}
/// `report_outcome` is the seam between repository traffic and the
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
///
/// @req-test: UR-002 - Access media when online or offline
/// @req-test: DR-013 - Repository pattern for online/offline data access
#[tokio::test]
async fn test_report_outcome_classifies_server_answered_as_reachable() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a server that answers should be reported reachable"
);
}
// Ok should also report reachable.
reporter.mark_unreachable_for_test().await;
let ok: Result<(), RepoError> = Ok(());
repo.report_outcome(&ok).await;
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
}
/// Local-side errors must NOT flip reachability — they say nothing about the
/// server.
#[tokio::test]
async fn test_report_outcome_ignores_local_errors() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
!reporter.is_reachable().await,
"local-side error must not change reachability"
);
}
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
async fn test_report_outcome_network_error_is_debounced() {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a single network failure stays online (debounced)"
);
}
#[tokio::test]
async fn test_get_audio_stream_url_formats_correctly() {
let repo = create_test_repository();
@@ -1403,6 +1790,46 @@ mod tests {
);
}
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
"expected HLS master playlist, got: {url}"
);
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository();
@@ -1563,4 +1990,43 @@ mod tests {
assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2");
}
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
#[test]
fn test_jray_context_deserializes_actors() {
// The jray?t= envelope as documented in the JRay truth file format.
let json = r#"{
"actors": [
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
]
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Tom Hanks");
assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
}
#[test]
fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
// Future fields (locations/trivia) must be ignored, and absent id keys
// must default to "" rather than failing to parse.
let json = r#"{
"actors": [ { "name": "Extra" } ],
"locations": ["Beach"],
"trivia": "filmed in 1994"
}"#;
let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
assert_eq!(ctx.actors.len(), 1);
assert_eq!(ctx.actors[0].name, "Extra");
assert_eq!(ctx.actors[0].imdb_id, "");
assert_eq!(ctx.actors[0].jellyfin_id, "");
}
}
+32
View File
@@ -104,6 +104,10 @@ pub struct MediaItem {
pub name: String,
#[serde(rename = "type")]
pub item_type: String,
/// Whether this item is a folder/container (vs a playable leaf). Used to
/// decide whether a channel item drills into a list or plays directly.
#[serde(default)]
pub is_folder: bool,
pub server_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
@@ -115,6 +119,10 @@ pub struct MediaItem {
pub genres: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub production_year: Option<i32>,
/// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
/// podcast episodes by release date.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub premiere_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub community_rating: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -249,12 +257,30 @@ pub struct PlaybackInfo {
pub needs_transcoding: bool,
}
/// Live stream information returned from opening a Live TV / channel stream.
///
/// Unlike on-demand video, a live channel must be "opened" before it can be
/// streamed; the server returns a transcoding URL (already absolute) plus a
/// `live_stream_id` that can later be used to close the stream.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveStreamInfo {
pub stream_url: String,
pub play_session_id: Option<String>,
pub live_stream_id: Option<String>,
pub media_source_id: Option<String>,
}
/// Genre
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Genre {
pub id: String,
pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
}
/// Image type
@@ -477,12 +503,14 @@ mod tests {
id: "1".to_string(),
name: "Test".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
@@ -616,12 +644,14 @@ mod tests {
id: "track1".to_string(),
name: "Test Track".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
@@ -675,12 +705,14 @@ mod tests {
id: "1".to_string(),
name: "Track".to_string(),
item_type: "Audio".to_string(),
is_folder: false,
server_id: "s1".to_string(),
parent_id: None,
library_id: None,
overview: None,
genres: None,
production_year: None,
premiere_date: None,
community_rating: None,
official_rating: None,
runtime_ticks: None,
+72
View File
@@ -112,6 +112,18 @@ impl SessionPollerManager {
match sessions_result {
Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len());
// In remote (cast) mode, mirror the remote session's
// now-playing onto the Android lockscreen. The local
// ExoPlayer is idle while casting, so without this the
// lockscreen shows stale local metadata and a frozen
// scrubber. Driving it here (native poll thread) rather
// than from the WebView keeps it live even when the
// screen is locked and JS timers are throttled.
if let PlaybackMode::Remote { session_id } = mode_manager.get_mode() {
Self::push_remote_lockscreen(&sessions, &session_id);
}
if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions,
@@ -150,6 +162,66 @@ impl SessionPollerManager {
*self.current_hint.write_safe() = hint;
}
/// Push the remote session's now-playing onto the Android lockscreen.
///
/// Looks up the active remote session by id and forwards its title/artist/
/// album, duration and position to the media notification. Silently does
/// nothing if the session isn't found or has no now-playing item (e.g. the
/// remote stopped) - the next state change will refresh it.
fn push_remote_lockscreen(
sessions: &[crate::jellyfin::client::SessionInfo],
session_id: &str,
) {
// 100ns Jellyfin ticks -> milliseconds.
const TICKS_PER_MS: i64 = 10_000;
let Some(session) = sessions
.iter()
.find(|s| s.id.as_deref() == Some(session_id))
else {
return;
};
let Some(now_playing) = session.now_playing_item.as_ref() else {
return;
};
let title = now_playing.name.clone().unwrap_or_default();
let artist = now_playing
.artists
.as_ref()
.map(|a| a.join(", "))
.filter(|s| !s.is_empty())
.or_else(|| now_playing.album_artist.clone())
.unwrap_or_default();
let album = now_playing.album.clone();
let duration_ms = now_playing.run_time_ticks.unwrap_or(0) / TICKS_PER_MS;
let (position_ms, is_playing) = session
.play_state
.as_ref()
.map(|ps| {
(
ps.position_ticks.unwrap_or(0) / TICKS_PER_MS,
!ps.is_paused.unwrap_or(false),
)
})
.unwrap_or((0, false));
let meta = crate::player::LockscreenMetadata {
title,
artist,
album,
duration_ms,
position_ms,
is_playing,
};
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
}
}
/// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint {
+26
View File
@@ -21,6 +21,8 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("014_series_audio_preferences", MIGRATION_014),
("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
];
/// Initial schema migration
@@ -667,3 +669,27 @@ const MIGRATION_016: &str = r#"
ALTER TABLE user_player_settings
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;
"#;
/// Migration to record whether a cached item is a folder/container vs a playable
/// leaf. Needed so channel items (which can be either) route to the player or to
/// a browse list correctly. Existing cached rows predate the column and have an
/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
/// causing the hybrid repository to re-fetch them from the server on next browse.
const MIGRATION_018: &str = r#"
ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
-- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL;
"#;
+255 -8
View File
@@ -197,6 +197,25 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle });
},
/**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
*/
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId });
},
/**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence.
*/
async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration });
},
/**
* Report that the HTML5 <video> finished loading and knows its duration.
*/
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
},
/**
* Preload upcoming tracks from the queue
* This queues background downloads for the next N tracks that aren't already downloaded
@@ -270,6 +289,31 @@ async remoteSessionSetVolume(sessionId: string, volume: number) : Promise<null>
async remoteSessionToggleMute(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId });
},
/**
* List current LMS sync groups.
*/
async lmsGetSyncGroups() : Promise<LmsSyncGroup[]> {
return await TAURI_INVOKE("lms_get_sync_groups");
},
/**
* Fuse LMS zones into a sync group. `master_mac` keeps playing and the
* `slave_macs` zones join it in sync.
*/
async lmsCreateSyncGroup(masterMac: string, slaveMacs: string[]) : Promise<null> {
return await TAURI_INVOKE("lms_create_sync_group", { masterMac, slaveMacs });
},
/**
* Remove a single LMS zone from its sync group (decouple one player).
*/
async lmsUnsyncPlayer(mac: string) : Promise<null> {
return await TAURI_INVOKE("lms_unsync_player", { mac });
},
/**
* Dissolve an entire LMS sync group, identified by its master's MAC.
*/
async lmsDissolveSyncGroup(masterMac: string) : Promise<null> {
return await TAURI_INVOKE("lms_dissolve_sync_group", { masterMac });
},
/**
* Set polling frequency hint based on UI state
*/
@@ -303,8 +347,8 @@ async playbackModeIsTransferring() : Promise<boolean> {
/**
* Transfer playback from local device to a remote Jellyfin session
*/
async playbackModeTransferToRemote(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId });
async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
},
/**
* Get remote session status (for polling position/duration)
@@ -322,6 +366,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks });
},
/**
* Set the transferring flag on the playback mode manager.
*
* Used by the frontend remote->local flow to mark the whole two-step sequence
* as a transfer, so `player_play_tracks` starts LOCAL playback instead of
* casting back to the remote session it's leaving. Always pair `true` with a
* later `false` (including on error) so the flag can't stick.
*/
async playbackModeSetTransferring(transferring: boolean) : Promise<null> {
return await TAURI_INVOKE("playback_mode_set_transferring", { transferring });
},
/**
* Initialize playback reporter (called after login)
*/
@@ -726,6 +781,31 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir });
},
/**
* 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.
*/
async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_download", { downloadId, streamUrl, targetDir });
},
/**
* 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.
*/
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir });
},
/**
* Get download manager statistics
*/
@@ -1007,6 +1087,14 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
},
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Returns an empty list when JRay isn't installed or
* has no data for the item, so the caller can render nothing without error.
*/
async repositoryJrayActorsAt(handle: string, itemId: string, t: number) : Promise<JRayActor[]> {
return await TAURI_INVOKE("repository_jray_actors_at", { handle, itemId, t });
},
/**
* Get latest items in a library
*/
@@ -1037,6 +1125,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit });
},
/**
* Get albums the user hasn't listened to recently ("rediscover")
*/
async repositoryGetRediscoverAlbums(handle: string, parentId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_rediscover_albums", { handle, parentId, limit });
},
/**
* Get genres for a library
*/
@@ -1046,8 +1140,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
/**
* Search for items
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options });
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
},
/**
* Get playback info for an item
@@ -1067,6 +1161,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
async repositoryGetLiveTvChannels(handle: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_live_tv_channels", { handle });
},
/**
* Get the root list of plugin "Channels"
*/
async repositoryGetChannels(handle: string) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_channels", { handle });
},
/**
* Open a live stream for a Live TV channel / live item
*/
async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStreamInfo> {
return await TAURI_INVOKE("repository_open_live_stream", { handle, itemId });
},
/**
* Report playback start
*/
@@ -1245,6 +1357,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
/** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/
@@ -1440,7 +1557,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
/**
* Genre
*/
export type Genre = { id: string; name: string }
export type Genre = { id: string; 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).
*/
albumCount: number | null }
/**
* Request to get an image URL (with caching)
*/
@@ -1457,14 +1580,47 @@ export type ImageOptions = { maxWidth?: number | null; maxHeight?: number | null
* Image type
*/
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo"
/**
* A single actor returned by the JRay plugin's "context at time t" endpoint.
*
* Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
* `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
* the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
*/
export type JRayActor = { name: string; imdb_id?: string; tmdb_id?: string; jellyfin_id?: string }
/**
* Library (media collection)
*/
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null }
/**
* Live stream information returned from opening a Live TV / channel stream.
*
* Unlike on-demand video, a live channel must be "opened" before it can be
* streamed; the server returns a transcoding URL (already absolute) plus a
* `live_stream_id` that can later be used to close the stream.
*/
export type LiveStreamInfo = { streamUrl: string; playSessionId: string | null; liveStreamId: string | null; mediaSourceId: string | null }
/**
* An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
*
* Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
* follow it in lockstep.
*/
export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?: string[]; slaveNames?: string[] }
/**
* Media item
*/
export type MediaItem = { id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
export type MediaItem = { id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
/**
* Media session type tracking the high-level playback context
*/
@@ -1579,7 +1735,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
/**
* Request to play tracks by ID (backend fetches metadata)
*/
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext }
export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffle: boolean; 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.
*/
startPosition?: number | null }
/**
* Playback information
*/
@@ -1760,6 +1921,82 @@ mergedIsPlaying: boolean;
* Volume from either local player or remote session (0-1 normalized)
*/
mergedVolume: number }
/**
* Events emitted by the player backend to the frontend via Tauri events.
*
* These are distinct from `PlayerEvent` in state.rs, which handles internal
* state machine transitions.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
export type PlayerStatusEvent =
/**
* Playback position updated (emitted periodically during playback)
*/
{ type: "position_update"; position: number; duration: number } |
/**
* Player state changed
*/
{ type: "state_changed"; state: string; media_id: string | null } |
/**
* Media has finished loading and is ready to play
*/
{ type: "media_loaded"; duration: number } |
/**
* Playback has ended naturally (reached end of media)
*/
{ type: "playback_ended" } |
/**
* Buffering state changed
*/
{ type: "buffering"; percent: number } |
/**
* An error occurred during playback
*/
{ type: "error"; message: string; recoverable: boolean } |
/**
* Volume changed
*/
{ type: "volume_changed"; volume: number; muted: boolean } |
/**
* Sleep timer state changed
*/
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control the frontend pauses it on this
* event.
*/
{ type: "sleep_timer_expired" } |
/**
* Show next episode popup with countdown
*/
{ type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } |
/**
* Countdown tick (emitted every second during autoplay countdown)
*/
{ type: "countdown_tick"; remaining_seconds: number } |
/**
* Queue changed (items added, removed, reordered, or playback mode changed)
*/
{ type: "queue_changed"; items: PlayerMediaItem[]; current_index: number | null; shuffle: boolean; repeat: RepeatMode; has_next: boolean; has_previous: boolean } |
/**
* Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
*/
{ type: "session_changed"; session: MediaSessionType } |
/**
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] } |
/**
* The user asked to disconnect from the remote session and resume locally.
*
* Emitted when the lockscreen Stop button is pressed while casting. The
* frontend owns the two-step remote->local transfer (it must reload the
* media item locally), so the native side only signals intent here.
*/
{ type: "remote_disconnect_requested" }
/**
* Result of creating a playlist
*
@@ -1776,7 +2013,17 @@ export type PlaylistEntry =
/**
* The underlying media item
*/
({ id: string; name: string; type: string; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
({ id: string; name: string; type: string;
/**
* Whether this item is a folder/container (vs a playable leaf). Used to
* decide whether a channel item drills into a list or plays directly.
*/
isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: string | null; overview?: string | null; genres?: string[] | null; productionYear?: number | null;
/**
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
* podcast episodes by release date.
*/
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
/**
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/
+1
View File
@@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
includeItemTypes: ["Audio"],
limit: 100,
},
requestId: 0,
});
});
});
+35 -2
View File
@@ -3,6 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { JRayActor } from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
@@ -11,6 +12,7 @@ import type {
GetItemsOptions,
SearchOptions,
PlaybackInfo,
LiveStreamInfo,
ImageType,
ImageOptions,
Genre,
@@ -89,6 +91,15 @@ export class RepositoryClient {
return commands.repositoryGetItem(this.ensureHandle(), itemId);
}
/**
* Query the optional JRay plugin for the actors on screen at time `t`
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
* or has no data for the item.
*/
async jrayActorsAt(itemId: string, t: number): Promise<JRayActor[]> {
return commands.repositoryJrayActorsAt(this.ensureHandle(), itemId, t);
}
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
}
@@ -109,12 +120,17 @@ export class RepositoryClient {
return commands.repositoryGetResumeMovies(this.ensureHandle(), 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[]> {
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
}
async search(query: string, options?: SearchOptions): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null);
async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
}
// ===== Playback Methods (all via Rust) =====
@@ -156,6 +172,23 @@ export class RepositoryClient {
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
async getLiveTvChannels(): Promise<MediaItem[]> {
return commands.repositoryGetLiveTvChannels(this.ensureHandle());
}
/** Browse the root list of plugin "Channels". Drill-down uses getItems(channelId). */
async getChannels(): Promise<SearchResult> {
return commands.repositoryGetChannels(this.ensureHandle());
}
/** Open a live stream for a Live TV channel / live item before HLS playback. */
async openLiveStream(itemId: string): Promise<LiveStreamInfo> {
return commands.repositoryOpenLiveStream(this.ensureHandle(), itemId);
}
// ===== URL Construction Methods (sync, no server call) =====
/**
+4
View File
@@ -5,12 +5,14 @@
// and adds frontend-only unions/helpers that have no backend equivalent.
export type {
ArtistItem,
AuthResult,
Genre,
GetItemsOptions,
ImageOptions,
ImageType,
Library,
LiveStreamInfo,
MediaItem,
MediaSource,
MediaStream,
@@ -50,6 +52,7 @@ export type ItemType =
| "CollectionFolder"
| "Channel"
| "ChannelFolderItem"
| "TvChannel"
| "Person";
export type LibraryType =
@@ -62,6 +65,7 @@ export type LibraryType =
| "boxsets"
| "playlists"
| "channels"
| "livetv"
| "unknown";
export type PersonType =
+7 -2
View File
@@ -2,6 +2,11 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
let { className = "fixed bottom-0 left-0 right-0 z-40" }: { className?: string } = $props();
// Determine if a route is active
function isActive(path: string): boolean {
@@ -15,7 +20,7 @@
</script>
<!-- Navigation bar visible on all platforms -->
<nav class="fixed bottom-0 left-0 right-0 bg-[var(--color-surface)] border-t border-gray-800 z-40">
<nav class="{className} bg-[var(--color-surface)] border-t border-gray-800">
<div class="flex items-center justify-around px-4 py-2">
<!-- Home Button -->
<button
@@ -43,7 +48,7 @@
<!-- Library Button -->
<button
onclick={() => goto('/library')}
onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library"
>
@@ -1,5 +1,6 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation";
@@ -152,7 +153,7 @@
class="flex-1 min-w-0 text-left"
>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.album_name}
{truncateMiddle(album.album_name, 40)}
</p>
<p class="text-xs text-gray-400 truncate">
{album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"}
@@ -91,15 +91,18 @@
// Get target directory for downloads
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++) {
try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) {
await commands.startDownload(downloadIds[i], streamUrl, targetDir);
await commands.enqueueDownload(downloadIds[i], streamUrl, targetDir);
}
} 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>
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
@@ -171,7 +172,7 @@
{/if}
</div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.name}
{truncateMiddle(album.name, 40)}
</p>
{#if album.productionYear}
<p class="text-xs text-gray-400">{album.productionYear}</p>
@@ -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");
});
});
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -159,7 +160,7 @@
<!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
{episode.name}
{truncateMiddle(episode.name, 64)}
</h1>
<!-- Metadata -->
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
@@ -134,7 +135,7 @@
{episodeNumber}.
</span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{episode.name}
{truncateMiddle(episode.name, 56)}
</h3>
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -125,7 +127,7 @@
selectedGenre = null;
genreItems = [];
} else {
goto(config.backPath);
navigateBack(config.backPath);
}
}
@@ -228,7 +230,7 @@
/>
</div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 40)}
</p>
{#if item.productionYear}
<p class="text-sm text-gray-400">
@@ -2,8 +2,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
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 SortButtonGroup 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 LibraryGrid from "./LibraryGrid.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
@@ -41,6 +46,7 @@
let items = $state<MediaItem[]>([]);
let loading = $state(true);
let gridWrapper = $state<HTMLDivElement | null>(null);
let searchQuery = $state("");
let debouncedSearchQuery = $state("");
let sortBy = $state<string>("");
@@ -74,12 +80,13 @@
const repo = auth.getRepository();
// 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()) {
const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType],
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
} else {
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType],
@@ -88,7 +95,7 @@
recursive: true,
limit: 10000,
});
items = result.items;
items = excludePodcasts(result.items);
}
} catch (e) {
console.error(`Failed to load ${config.itemType}:`, e);
@@ -124,7 +131,7 @@
}
function goBack() {
goto(config.backPath);
navigateBack(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
@@ -142,6 +149,54 @@
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>
<div class="space-y-6">
@@ -192,10 +247,19 @@
<p>No {config.title.toLowerCase()} found</p>
</div>
{:else}
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{:else if config.displayComponent === "tracklist"}
<TrackList tracks={items} onTrackClick={handleTrackClick} />
{/if}
<div class="flex gap-2">
<div bind:this={gridWrapper} class="flex-1 min-w-0">
{#if config.displayComponent === "grid"}
<LibraryGrid items={items} onItemClick={handleItemClick} musicContent={["MusicAlbum", "MusicArtist", "Audio", "Playlist"].includes(config.itemType)} />
{: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}
</div>
@@ -69,12 +69,14 @@
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
{: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">
{#each items as item (item.id)}
<MediaCard
{item}
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
{#each items as item, index (item.id)}
<div data-grid-index={index}>
<MediaCard
{item}
showProgress={true}
onclick={() => onItemClick?.(item)}
/>
</div>
{/each}
</div>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -68,6 +69,7 @@
<button
type="button"
data-grid-index={index}
onclick={() => onItemClick?.(item)}
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
>
@@ -105,7 +107,7 @@
<!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 56)}
</p>
{#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads";
import CachedImage from "$lib/components/common/CachedImage.svelte";
@@ -166,7 +167,7 @@
<div class="mt-2 space-y-0.5">
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{item.name}
{truncateMiddle(item.name, 40)}
</p>
{#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
@@ -48,10 +48,8 @@
async function handlePlayAll() {
if (entries.length === 0) return;
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id);
await commands.playerPlayTracks(repositoryHandle, {
await playerController.playTracks({
trackIds,
startIndex: 0,
shuffle: false,
@@ -70,10 +68,8 @@
async function handleShufflePlay() {
if (entries.length === 0) return;
try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id);
await commands.playerPlayTracks(repositoryHandle, {
await playerController.playTracks({
trackIds,
startIndex: 0,
shuffle: true,
@@ -71,6 +71,13 @@
// Pin the season item
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) {
console.error("Failed to start season download:", error);
} finally {
@@ -64,9 +64,12 @@
// Pin the series item
await downloads.pinItem(seriesId);
// Start downloads (the backend will handle queuing)
// For now, we'll rely on a download manager to pick them up
// TODO: Implement batch download start
// 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) {
console.error("Failed to start series download:", error);
} finally {
+7 -15
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue";
import { auth } from "$lib/stores/auth";
import { currentMedia } from "$lib/stores/player";
import { toast } from "$lib/stores/toast";
import type { MediaItem } from "$lib/api/types";
@@ -53,17 +53,10 @@
try {
isPlayingTrack = track.id;
// Validate auth before proceeding
const repo = auth.getRepository();
if (!repo) {
throw new Error("Not authenticated");
}
// If this is an album, use the backend album command (more efficient)
if (context && context.type === "album") {
const repositoryHandle = repo.getHandle();
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
await commands.playerPlayAlbumTrack(repositoryHandle, {
await playerController.playAlbumTrack({
albumId: context.albumId,
albumName: context.albumName,
trackId: track.id,
@@ -74,7 +67,6 @@
// Use new backend command for non-album contexts (playlists, custom queues, etc.)
// Backend handles all metadata fetching and queue building
const repositoryHandle = repo.getHandle();
const trackIds = tracks.map((t) => t.id);
// Determine context for queue
@@ -89,7 +81,7 @@
playContext = { type: "custom", label: null };
}
await commands.playerPlayTracks(repositoryHandle, {
await playerController.playTracks({
trackIds,
startIndex: index,
shuffle: false,
@@ -204,7 +196,7 @@
<!-- Track Rows -->
<div class="space-y-1">
{#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 -->
<button
onclick={() => handleTrackClick(track, index)}
@@ -242,7 +234,7 @@
<span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
>
{track.name}
{truncateMiddle(track.name, 48)}
</span>
</div>
@@ -356,7 +348,7 @@
{#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span>
{/if}
{track.name}
{truncateMiddle(track.name, 48)}
</p>
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
{#if showArtist && showAlbum}
@@ -28,6 +28,7 @@ vi.mock("$lib/stores/queue", () => ({
setQueue: vi.fn(),
addToQueue: vi.fn(),
},
currentQueueItem: { subscribe: vi.fn((fn: any) => { fn(null); return () => {}; }) },
}));
vi.mock("./DownloadButton.svelte", () => ({
+10 -10
View File
@@ -1,9 +1,9 @@
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
import {
@@ -74,28 +74,28 @@
async function handleSeekEnd() {
seeking = false;
seekPending = true; // Keep showing target position until backend catches up
await commands.playerSeek(seekValue);
await playerController.seek(seekValue);
}
// Control handlers for Controls component
async function handlePlayPause() {
await commands.playerToggle();
await playerController.toggle();
}
async function handlePrevious() {
await commands.playerPrevious();
await playerController.previous();
}
async function handleNext() {
await commands.playerNext();
await playerController.next();
}
async function handleToggleShuffle() {
await commands.playerToggleShuffle();
await playerController.toggleShuffle();
}
async function handleCycleRepeat() {
await commands.playerCycleRepeat();
await playerController.cycleRepeat();
}
// Prefer album ID for artwork (all tracks in an album share the same cover)
@@ -128,7 +128,7 @@
async function handleQueueItemClick(index: number) {
try {
queue.skipTo(index);
await commands.playerSkipTo(index);
await playerController.skipTo(index);
} catch (e) {
console.error("Failed to skip to queue item:", e);
}
@@ -242,7 +242,7 @@
<div class="p-6 space-y-6 flex-shrink-0">
<!-- Title & Artist -->
<div class="text-center">
<h1 class="text-2xl font-bold text-white truncate">{displayMedia?.name}</h1>
<h1 class="text-2xl font-bold text-white truncate">{truncateMiddle(displayMedia?.name, 48)}</h1>
<div class="text-lg text-gray-400 mt-1 flex items-center justify-center gap-1 flex-wrap">
{#if displayMedia?.artistItems?.length}
{#each displayMedia?.artistItems as artist, i}
+9 -8
View File
@@ -14,7 +14,8 @@
* @req: UR-010 - Control playback of Jellyfin remote sessions
*/
import { commands } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -105,23 +106,23 @@
// Control handlers for Controls component
async function handlePlayPause() {
await commands.playerToggle();
await playerController.toggle();
}
async function handlePrevious() {
await commands.playerPrevious();
await playerController.previous();
}
async function handleNext() {
await commands.playerNext();
await playerController.next();
}
async function handleToggleShuffle() {
await commands.playerToggleShuffle();
await playerController.toggleShuffle();
}
async function handleCycleRepeat() {
await commands.playerCycleRepeat();
await playerController.cycleRepeat();
}
// Scrubbing (seek) handler
@@ -133,7 +134,7 @@
const newPosition = percent * displayDuration;
try {
await commands.playerSeek(newPosition);
await playerController.seek(newPosition);
haptics.tap();
} catch (err) {
console.error("Failed to seek:", err);
@@ -322,7 +323,7 @@
<div
class="text-sm font-medium text-white truncate block w-full text-left"
>
{displayMedia?.name}
{truncateMiddle(displayMedia?.name, 40)}
</div>
<div class="text-xs text-gray-400 truncate flex items-center gap-1">
{#if displayMedia?.artistItems?.length}
+5 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { commands } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -79,7 +80,7 @@
queue.moveInQueue(fromIndex, toIndex);
// Sync with backend
await commands.playerMoveInQueue(fromIndex, toIndex);
await playerController.moveInQueue(fromIndex, toIndex);
} catch (e) {
console.error("Failed to move queue item:", e);
// The store already updated optimistically, refresh if needed
@@ -106,7 +107,7 @@
e.stopPropagation();
try {
queue.removeFromQueue(index);
await commands.playerRemoveFromQueue(index);
await playerController.removeFromQueue(index);
} catch (err) {
console.error("Failed to remove from queue:", err);
}
@@ -198,7 +199,7 @@
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
{item.name}
{truncateMiddle(item.name, 48)}
</p>
{#if item.artists?.length}
<p class="text-xs text-gray-400 truncate">
@@ -0,0 +1,221 @@
/**
* VideoPlayer scrub regression tests (Android backend path)
*
* Reproduces the reported bug: with a sleep timer active, scrubbing the
* video seek bar "seeks, then jumps back to the old position".
*
* Root cause history:
* - Native init called onDestroy() after an await -> lifecycle_outside_component
* -> the catch treated init as failed and silently flipped useHtml5Element to
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
* - The native SurfaceView has never been visible through the webview, so the
* INTERIM behavior (until the video-player API refactor) is: when the backend
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
* and stops the native backend (single audio source, webview owns playback).
*
* These tests pin the interim behavior: Android's native response is
* overridden, the backend is stopped exactly once, and scrubbing keeps
* working (and holds its position) with a sleep timer active.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
strategy: "native",
position,
}));
const playerStop = vi.fn(async () => ({}));
const playerToggle = vi.fn(async () => ({ state: "playing" }));
const playerSetSleepTimer = vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 }));
const playerCancelSleepTimer = vi.fn(async () => ({ mode: { kind: "off" }, remainingSeconds: 0 }));
vi.mock("$lib/api/bindings", () => ({
commands: {
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
playerStop: (...a: any[]) => playerStop(...(a as [])),
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
playerSetSleepTimer: (...a: any[]) => playerSetSleepTimer(...(a as [any])),
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
playerSetSubtitleTrack: vi.fn(async () => ({})),
playerSwitchAudioTrack: vi.fn(async () => ({})),
storageGetSeriesAudioPreference: vi.fn(async () => null),
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
},
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({
getHandle: () => "repo-1",
getSubtitleUrl: async () => "",
jrayActorsAt: async () => [],
}),
},
}));
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
// Use the REAL sleepTimer store module so timer activation flows exactly as
// in production (playerEvents.ts writes to it on every backend tick).
import { render, fireEvent, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import type { MediaItem } from "$lib/api/types";
function makeEpisode(): MediaItem {
return {
id: "ep1",
name: "Episode 1",
type: "Episode",
runTimeTicks: 24 * 60 * 10_000_000, // 24 min
} as MediaItem;
}
/** Simulate one backend sleep-timer tick, exactly as playerEvents.ts does. */
function sleepTimerTick(remaining = 2) {
sleepTimer.set({
mode: { kind: "episodes", remaining },
remainingSeconds: 0,
});
}
async function mountAndroidPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
streamUrl: "http://server/videos/ep1/master.m3u8",
mediaSourceId: "src-1",
needsTranscoding: false,
onClose: vi.fn(),
},
});
// Init: backend reports native, component overrides to HTML5 and stops it.
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector(
'input[type="range"]'
) as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
expect(video).not.toBeNull();
return { ...utils, slider, video };
}
/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(
slider: HTMLInputElement,
video: HTMLVideoElement,
target: number
) {
await fireEvent.mouseDown(slider);
slider.value = String(target);
await fireEvent.input(slider);
await fireEvent.change(slider);
await fireEvent.mouseUp(slider);
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
await fireEvent(video, new Event("seeked"));
await tick();
}
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
sleepTimerExpiredSignal.set(0);
});
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
await mountAndroidPlayer();
// The native backend must be stopped so it doesn't play audio behind the
// webview (frozen picture + double audio source).
expect(playerStop).toHaveBeenCalledTimes(1);
});
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
const { slider, video } = await mountAndroidPlayer();
await scrubTo(slider, video, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith(
"repo-1",
600,
"src-1",
null,
true // HTML5 path: the webview owns playback after the override
)
);
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
const { slider, video } = await mountAndroidPlayer();
// Enable "2 more episodes" timer; backend then ticks every second.
sleepTimerTick(2);
await tick();
sleepTimerTick(2);
await tick();
await scrubTo(slider, video, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
expect(parseFloat(slider.value)).toBeCloseTo(600);
// Timer ticks after the seek must not snap the bar back.
sleepTimerTick(2);
await tick();
expect(parseFloat(slider.value)).toBeCloseTo(600);
// A second scrub must also work.
await scrubTo(slider, video, 900);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
expect(parseFloat(slider.value)).toBeCloseTo(900);
});
it("sleep-timer ticks alone never move the seek bar", async () => {
const { slider } = await mountAndroidPlayer();
const before = slider.value;
for (let i = 0; i < 5; i++) {
sleepTimerTick(2);
await tick();
}
expect(slider.value).toBe(before);
expect(playerSeekVideo).not.toHaveBeenCalled();
});
});

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