Compare commits

...

34 Commits

Author SHA1 Message Date
a2cd9978f0 build uses android signing key
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m13s
Build & Release / Run Tests (push) Successful in 5m4s
Build & Release / Build Linux (push) Successful in 17m29s
Build & Release / Build Android (push) Failing after 22m13s
Build & Release / Create Release (push) Has been skipped
2026-07-07 18:05:17 +02:00
36be192d44 offline mode fixes
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m39s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m11s
2026-07-07 16:22:12 +02:00
acb7e5f221 fix offline mode and layout bugs
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m32s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Build & Release / Build Linux (push) Successful in 17m31s
Build & Release / Build Android (push) Successful in 22m5s
Build & Release / Create Release (push) Successful in 16s
2026-07-06 20:24:46 +02:00
68c8602230 Merge pull request 'fix-launcher-offline-mode' (#9) from fix-android-launcher-icon-conflict into master
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Reviewed-on: #9
2026-07-03 17:58:30 +00:00
2d141e5bf4 Fix for offline mode
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s
2026-07-03 19:37:34 +02:00
c58cc0cf46 CI: replace broken per-commit Android APK build with a fast compile check
build-and-test.yml built a full APK on every master push without running
sync-android-sources.sh, so it used the wrong (Tauri-default) sources, was
unsigned, and duplicated the ~15min build that build-release.yml does properly
on tags. Replace it with cargo check --target aarch64-linux-android (~1min),
which catches Android Rust breakage without linking, bundling, or signing.
The signed release APK remains a tag-only artifact from build-release.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:48:14 +02:00
8938e3fdba Android launcher: drop monochrome (themed) icon, keep color only
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:47:50 +02:00
e2c12615c5 Fix CI apk build
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m14s
Traceability Validation / Check Requirement Traces (push) Successful in 27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 29m11s
Build & Release / Run Tests (push) Successful in 4m35s
Build & Release / Build Linux (push) Successful in 17m28s
Build & Release / Build Android (push) Successful in 21m35s
Build & Release / Create Release (push) Successful in 11s
2026-07-02 21:57:28 +02:00
0b5a3aa176 Merge pull request 'player-adapter-contract' (#8) from player-adapter-contract into master
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m52s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m41s
Reviewed-on: #8
2026-07-02 18:02:17 +00:00
37455bc470 Use incremental build
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m57s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 19m5s
2026-07-02 20:01:43 +02:00
a64e1b1fb4 Introduce PlayerAdapter contract; decision logic shared in Rust backend
Establish a decoupled player boundary so UI and backend interact with video
through one contract, with the HTML5 (Linux/interim-Android) and native
(ExoPlayer) providers as interchangeable primitive-executor adapters.

- PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the
  adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource,
  play/pause, setVolume, selectSubtitle); it never branches on strategy.
- Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track
  return a strategy); the facade dispatches the chosen primitive to the active
  adapter. Both providers share the one decision path — logic lives once, in Rust.
- Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets
  backend control (lockscreen/remote/sleep) drive the webview <video> element.
- Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause
  silently no-opping when the element was re-bound).
- Do not emit a "stopped" player state on natural end-of-video: it flipped the
  player/mode to idle mid-handoff and suppressed next-episode auto-advance under
  a sleep timer. Jellyfin progress reporting is preserved; the backend's
  on_video_playback_ended owns the transition.
- VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter).
- Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:56:20 +02:00
1f6977cd01 Playback fix
All checks were successful
🏗️ 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
6af7f7dcca Fix android playback issue
All checks were successful
🏗️ 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
75014ee00f Fix sleep bug, fix menu return
All checks were successful
🏗️ 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
342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
All checks were successful
🏗️ 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
dcee342c47 Jray mugshots of actors shown
All checks were successful
🏗️ 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
78f5cd9db9 Fix playback regression
Some checks failed
🏗️ 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
0eae81ec59 Add JRay support
Some checks failed
🏗️ 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
8eae4ae253 layout improvements
Some checks failed
🏗️ 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
ef7be645b3 Merge pull request 'fix/lockscreen-mediasession-sync' (#7) from fix/lockscreen-mediasession-sync into master
All checks were successful
🏗️ 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
b9249f72e9 rescale logo
All checks were successful
🏗️ 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
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
345bd0730c Merge pull request 'feat/plugin-channel-support' (#6) from feat/plugin-channel-support into master
All checks were successful
🏗️ 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
e1e50d51e0 Use different app logo
All checks were successful
🏗️ 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
7d7f27aa10 feat(library and playback): Support for serverside channel plugins and hls streaming 2026-06-27 17:25:57 +02:00
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
ff8f35084b Merge pull request 'feat(library): genre sliders, artist links, and navigation utils' (#5) from feat/library-genre-sliders-and-nav-utils into master
All checks were successful
🏗️ 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
4634ed595c fix(remote playback): Move audio between remote players
All checks were successful
🏗️ 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
6836ce79c8 fix(Remote playback): kludge to scrub after stream move
All checks were successful
🏗️ 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
2811e1b7ca fix: several small fixes
All checks were successful
🏗️ 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
1836615dc0 feat(library): genre sliders, artist links, and navigation utils
All checks were successful
🏗️ 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
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
All checks were successful
🏗️ 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
17a35573a0 feat(library): focused music/TV/movie landing screens + self-draining download queue
All checks were successful
🏗️ 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
dcf08f30bc fix: Autoplay now resets time to zero and ignores trigger if episode already started (#3)
All checks were successful
🏗️ 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
179 changed files with 10792 additions and 1212 deletions

View File

@ -60,16 +60,24 @@ jobs:
cargo test cargo test
cd .. cd ..
build: # Fast per-commit Android compile check. This does NOT build a shippable APK:
name: Build Android APK # the full signed release APK is built only on tag pushes by build-release.yml
# (which runs sync-android-sources.sh + signing). Running the full bundle here
# too would duplicate a ~15min build and, without the sync step, produced an
# unsigned APK missing our custom sources/icons/proguard rules anyway.
# `cargo check` for the Android target (~1min) catches Android-specific Rust
# breakage without linking, bundling, or signing.
android-check:
name: Android Compile Check
runs-on: linux/amd64 runs-on: linux/amd64
needs: test needs: test
container: container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 27.0.11902837 ANDROID_SDK_ROOT: /opt/android-sdk
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837 NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps: steps:
- name: Checkout repository - name: Checkout repository
@ -97,42 +105,13 @@ jobs:
${{ runner.os }}-bun- ${{ runner.os }}-bun-
- name: Install dependencies - name: Install dependencies
run: | run: bun install
bun install
- name: Build frontend - name: Cargo check (aarch64-linux-android)
run: bun run build
- name: Ensure Android NDK
run: |
if [ ! -d "$NDK_HOME" ]; then
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
fi
echo "Using NDK at $NDK_HOME"
ls "$NDK_HOME"
- name: Initialize Android project
run: | run: |
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
export AR_aarch64_linux_android="$TC/llvm-ar"
cd src-tauri cd src-tauri
echo "" | bunx tauri android init cargo check --target aarch64-linux-android --lib
cd ..
- name: Build Android APK
id: build
run: |
mkdir -p artifacts
bun run tauri android build --apk true --target aarch64
# Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-apk
path: ${{ steps.build.outputs.artifact }}
retention-days: 30
if-no-files-found: error

View File

@ -95,20 +95,28 @@ jobs:
echo "" echo ""
# Check each file # Check each file
MISSING_TRACES=0 # Pipe into the loop instead of a here-string (<<<) so this step works
while IFS= read -r file; do # under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
# for the same reason. The loop runs in a subshell (so a counter var
# wouldn't survive), so we record warnings in a temp file and count it
# afterwards.
MISSING_FILE=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
# Skip test files # Skip test files
if [[ "$file" == *".test."* ]]; then case "$file" in
continue *.test.*) continue ;;
fi esac
if [ -f "$file" ]; then if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file" echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1)) echo "$file" >> "$MISSING_FILE"
fi fi
fi fi
done <<< "$CHANGED" done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then if [ "$MISSING_TRACES" -gt 0 ]; then
echo "" echo ""

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. A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.

View File

@ -90,6 +90,50 @@ flowchart LR
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command. **Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## HTML5 Video Adapter (webview-rendered video)
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs`
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
the real player, living outside Rust's reach.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
```mermaid
flowchart LR
subgraph Webview["Webview"]
Video["HTML5 <video> / HLS.js"]
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
end
subgraph Backend["Rust"]
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
Controller["PlayerController"]
Emitter["TauriEventEmitter"]
end
subgraph Frontend["Frontend"]
Events["playerEvents.ts"]
Store["player store"]
end
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
```
**Key points:**
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
60fps RAF loop.
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
("frontend only displays state and invokes commands") for the video path.
## MpvBackend (Linux) ## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/` **Location**: `src-tauri/src/player/mpv/`

View File

@ -13,6 +13,7 @@ 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. - **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. - **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`). - **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. - **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback. - **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions. - **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.
@ -166,6 +167,9 @@ src/lib/
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines) │ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
│ ├── client.ts # JellyfinClient (helper for streaming) │ ├── client.ts # JellyfinClient (helper for streaming)
│ └── sessions.ts # SessionsApi (remote session control) │ └── 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/ ├── services/
│ ├── playerEvents.ts # Tauri event listener for player events │ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines) │ └── playbackReporting.ts # Thin wrapper (~50 lines)

BIN
docs/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -3,15 +3,19 @@
set -e set -e
BUILD_TYPE="${1:-debug}"
echo "🚀 Build and Deploy Android APK" echo "🚀 Build and Deploy Android APK"
echo "" echo ""
# Build APK # Pass all args (build type and/or --clean) through to the build script.
./scripts/build-android.sh "$BUILD_TYPE" ./scripts/build-android.sh "$@"
echo "" echo ""
# Deploy APK # Deploy APK — extract build type (default debug), ignoring flags like --clean.
BUILD_TYPE="debug"
for arg in "$@"; do
case "$arg" in
debug|release) BUILD_TYPE="$arg" ;;
esac
done
./scripts/deploy-android.sh "$BUILD_TYPE" ./scripts/deploy-android.sh "$BUILD_TYPE"

View File

@ -15,13 +15,24 @@ echo "Android SDK: $ANDROID_HOME"
echo "NDK: $NDK_HOME" echo "NDK: $NDK_HOME"
echo "" echo ""
# Build type: debug or release (default: debug) # Parse args: build type (debug/release) and optional --clean flag.
BUILD_TYPE="${1:-debug}" # By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}"
for arg in "$@"; do
case "$arg" in
--clean) CLEAN=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
# Step 0: Clear build caches to ensure fresh builds # Step 0: Optionally clear build caches for a fully fresh build.
echo "🧹 Clearing build caches..." if [ "$CLEAN" = "1" ]; then
echo "🧹 Clearing build caches (clean build)..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1 npm install > /dev/null 2>&1
fi
# Step 1: Sync Android source files # Step 1: Sync Android source files
echo "🔄 Syncing Android sources..." echo "🔄 Syncing Android sources..."
@ -33,6 +44,9 @@ bun run build
# Step 2: Build Android APK # Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then if [ "$BUILD_TYPE" = "release" ]; then
# Configure release signing from .env (single source of truth). Must run
# after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh
echo "📦 Building release APK..." echo "📦 Building release APK..."
bun run tauri android build --apk true bun run tauri android build --apk true
else else

View File

@ -41,4 +41,49 @@ if [ -f "$APP_GRADLE_SRC" ]; then
echo " Copied: app/build.gradle.kts" echo " Copied: app/build.gradle.kts"
fi fi
# Custom ProGuard/R8 keep rules. Required for minified release builds:
# the player/ and security/ Kotlin classes are loaded by name via JNI from
# Rust, so R8 can't see the references and would strip them without this.
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
if [ -f "$PROGUARD_SRC" ]; then
cp "$PROGUARD_SRC" "$PROGUARD_DST"
echo " Copied: app/proguard-jellytau.pro"
fi
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
# low-quality launcher icons from tauri.conf.json (which has no high-res
# Android source), so overwrite them with the real committed mipmaps.
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
if [ -d "$RES_SRC" ]; then
for dir in "$RES_SRC"/mipmap-*; do
[ -d "$dir" ] || continue
name="$(basename "$dir")"
mkdir -p "$RES_DST/$name"
cp "$dir"/* "$RES_DST/$name/"
echo " Copied res: $name"
done
# We ship only the color adaptive icon (background + foreground). Drop any
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
# render well, and our adaptive-icon xml no longer references it, so a stray
# ic_launcher_monochrome.png would just be dead weight.
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
# as API-qualified VECTOR drawables:
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
# Because drawable-v24 is a more specific match than our unqualified
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
# the green square robot instead of our jellyfish. Remove them so the
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
# to the real committed PNGs.
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_background.xml
fi
echo "✓ Android sources synced successfully" echo "✓ Android sources synced successfully"

View File

@ -0,0 +1,52 @@
#!/bin/bash
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
#
# .env is the single source of truth for local release signing. `tauri android
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
# from .env before every release build (this is the local mirror of what the CI
# workflow does from Gitea secrets).
#
# Required .env vars:
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE not found — cannot configure release signing." >&2
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
exit 1
fi
# Load .env without leaking it into the caller's environment beyond what we need.
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
exit 1
fi
mkdir -p "$(dirname "$PROPS")"
umask 077
cat > "$PROPS" <<EOF
storeFile=$ANDROID_KEYSTORE_FILE
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
echo "🔐 Wrote release signing config to keystore.properties (from .env)"

9
src-tauri/Cargo.lock generated
View File

@ -2028,6 +2028,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rusqlite", "tokio-rusqlite",
"tokio-util", "tokio-util",
"urlencoding",
"uuid", "uuid",
] ]
@ -4959,6 +4960,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@ -5281,7 +5288,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@ -33,6 +33,7 @@ rand = "0.8"
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] } tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
tokio-util = "0.7" tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
urlencoding = "2"
futures-util = "0.3" futures-util = "0.3"
async-trait = "0.1" async-trait = "0.1"

View File

@ -0,0 +1,14 @@
# JellyTau custom keep rules.
#
# These classes are loaded by name from the Rust backend via JNI
# (env.find_class / class-loader lookups), so R8 cannot see the
# references and would otherwise strip or rename them in a minified
# release build causing an instant ClassNotFoundException crash on
# startup. See src-tauri/src/player/android/mod.rs and
# src-tauri/src/credentials.rs.
-keep class com.dtourolle.jellytau.player.** { *; }
-keep class com.dtourolle.jellytau.security.** { *; }
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**

View File

@ -15,6 +15,8 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
object VideoOverlayManager { object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null 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. * Attach the video SurfaceView to the Activity's content view.
@ -51,6 +53,23 @@ object VideoOverlayManager {
contentView.addView(surfaceView, 0, layoutParams) contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView 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") android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e) android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
@ -64,6 +83,7 @@ object VideoOverlayManager {
*/ */
fun detachVideoSurface(activity: Activity) { fun detachVideoSurface(activity: Activity) {
try { try {
removeLayoutListener()
attachedSurfaceView?.let { surfaceView -> attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content) val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView) contentView.removeView(surfaceView)
@ -83,4 +103,12 @@ object VideoOverlayManager {
fun isVideoSurfaceAttached(): Boolean { fun isVideoSurfaceAttached(): Boolean {
return attachedSurfaceView != null return attachedSurfaceView != null
} }
private fun removeLayoutListener() {
contentLayoutListener?.let { listener ->
listenerContentView?.removeOnLayoutChangeListener(listener)
}
contentLayoutListener = null
listenerContentView = null
}
} }

View File

@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
val jellyTauPlayer = JellyTauPlayer.getInstance() val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer() 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) { wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() { override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play") nativeOnMediaCommand("play")
} }
override fun pause() { override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause") nativeOnMediaCommand("pause")
} }
override fun seekToNext() { override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next") nativeOnMediaCommand("next")
} }
override fun seekToPrevious() { override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous") nativeOnMediaCommand("previous")
} }
override fun seekTo(positionMs: Long) { override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0 val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds") nativeOnMediaCommand("seek:$positionSeconds")
} }
override fun stop() { override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop") nativeOnMediaCommand("stop")
} }
} }
@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
) )
isActive = true 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() { setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() { override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play() nativeOnMediaCommand("play")
} }
override fun onPause() { override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause() nativeOnMediaCommand("pause")
} }
override fun onSkipToNext() { override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext() nativeOnMediaCommand("next")
} }
override fun onSkipToPrevious() { override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious() nativeOnMediaCommand("previous")
} }
override fun onStop() { override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed") android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop() nativeOnMediaCommand("stop")
} }
override fun onSeekTo(position: Long) { override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position") 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() .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. * Update the MediaSession metadata and playback state, plus the notification.
* This updates both the MediaSession and 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( fun updateMediaMetadata(
title: String, title: String,
@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
) { ) {
val session = mediaSessionCompat ?: return val session = mediaSessionCompat ?: return
lastTitle = title
lastArtist = artist
lastIsPlaying = isPlaying
// Update MediaSession metadata // Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder() val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
@ -280,7 +293,59 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build()) session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state // Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder() session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
// before) enableRemoteVolume(); this keeps the session routed to the
// remote (absolute) volume slider instead of the local media stream.
if (isRemoteVolumeEnabled) {
volumeProvider?.let { session.setPlaybackToRemote(it) }
}
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* 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.
*
* While remote volume control is enabled (casting), the state is forced to
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
* (absolute) volume slider for a session that is actively playing; if a
* periodic metadata/position push reports paused (e.g. before the remote
* session has actually started), reporting STATE_PAUSED here makes the
* system tear down the remote slider set up by setPlaybackToRemote() and
* fall back to the local media-stream volume.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
val playing = isPlaying || isRemoteVolumeEnabled
return PlaybackStateCompat.Builder()
.setActions( .setActions(
PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PAUSE or
@ -290,15 +355,11 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_SEEK_TO PlaybackStateCompat.ACTION_SEEK_TO
) )
.setState( .setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position, position,
1.0f if (playing) 1.0f else 0.0f
) )
.build()
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
} }
/** /**

View File

@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
/** Current media ID being played */ /** Current media ID being played */
private var currentMediaId: String? = null private var currentMediaId: String? = null
/**
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
* end of a transcoded stream), which would otherwise notify the backend
* twice and, for example, decrement the sleep-timer episode counter twice.
* Reset whenever new media is loaded.
*/
private var endedNotified = false
/** Current media metadata for notification updates */ /** Current media metadata for notification updates */
private var currentTitle: String = "" private var currentTitle: String = ""
private var currentArtist: String = "" private var currentArtist: String = ""
@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
/** SurfaceView for video playback */ /** SurfaceView for video playback */
private var surfaceView: SurfaceView? = null private var surfaceView: SurfaceView? = null
private var surfaceHolder: SurfaceHolder? = 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 currentMediaType: MediaType = MediaType.AUDIO
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null 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 // Create ExoPlayer with audio focus handling
exoPlayer = ExoPlayer.Builder(appContext) exoPlayer = ExoPlayer.Builder(appContext)
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
// Pause when the audio output is removed (wired headphones unplugged or
// Bluetooth device disconnected). ExoPlayer listens for the system
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
// the lockscreen notification in sync automatically.
.setHandleAudioBecomingNoisy(true)
.build() .build()
// Set up player listener // Set up player listener
@ -202,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
// Playback completed // Playback completed
android.util.Log.d("JellyTauPlayer", "▶ Playback ended") android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
stopPositionUpdates() stopPositionUpdates()
// 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() nativeOnPlaybackEnded()
} else {
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
}
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
android.util.Log.d("JellyTauPlayer", "▶ Buffering...") android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
@ -239,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
} }
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { 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() { override fun onRenderedFirstFrame() {
@ -316,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
fun load(url: String, mediaId: String) { fun load(url: String, mediaId: String) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare() exoPlayer.prepare()
@ -546,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
) { ) {
mainHandler.post { mainHandler.post {
currentMediaId = mediaId currentMediaId = mediaId
endedNotified = false
// Store metadata for notification updates // Store metadata for notification updates
currentTitle = title currentTitle = title
@ -735,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine") android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) { while (isActive) {
if (exoPlayer.isPlaying) { 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 val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration") android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(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) delay(POSITION_UPDATE_INTERVAL_MS)
} }
@ -842,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
/** /**
* Resize the video surface (for orientation changes). * 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) { 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 { mainHandler.post {
surfaceView?.let { view -> val view = surfaceView ?: return@post
view.layoutParams = view.layoutParams.apply { val parent = view.parent as? ViewGroup
this.width = width // Available area: prefer the parent's measured size, fall back to the screen.
this.height = height 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() view.requestLayout()
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}") android.util.Log.d(
} "JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
)
} }
} }

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

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

BIN
src-tauri/icons/64x64.png Normal file

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

View File

@ -133,7 +133,7 @@ impl AuthManager {
log::info!("[AuthManager] Connecting to server: {}", normalized_url); log::info!("[AuthManager] Connecting to server: {}", normalized_url);
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await { match self.http_client.get_json_fast::<PublicSystemInfo>(&endpoint).await {
Ok(info) => { Ok(info) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version); log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);

View File

@ -0,0 +1,455 @@
//! Tauri commands for the offline "browse & queue" feature.
//!
//! Two backend pieces support browsing the full server catalog while offline
//! and queueing downloads that fire on reconnect:
//!
//! - [`sync_full_catalog`] walks every library while online and persists all
//! items to the offline cache so the whole catalog is browsable (greyed out)
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
//! what `get_items` branch 3 serves offline).
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
//! heal-and-pump pattern in `player_preload_upcoming`.
use std::sync::Arc;
use log::{info, warn};
use tauri::State;
use crate::commands::repository::RepositoryManagerWrapper;
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
use crate::commands::storage::DatabaseWrapper;
use crate::repository::types::GetItemsOptions;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// app_settings key holding the RFC-3339 timestamp of the last successful
/// full-catalog sync.
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
/// Item types worth caching for offline browsing: containers the library
/// landing pages render plus the playable leaves users queue for download.
const CATALOG_ITEM_TYPES: &[&str] = &[
"MusicAlbum",
"Movie",
"Series",
"Season",
"Episode",
"Audio",
"BoxSet",
];
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncResult {
/// Total items persisted to the offline cache across all libraries.
pub items_cached: usize,
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
pub libraries_failed: usize,
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncStatus {
/// RFC-3339 timestamp of the last successful sync, if any.
pub last_synced_at: Option<String>,
}
/// Walk every library on the server and persist all items to the offline cache
/// so the full catalog is browsable offline (greyed out when not downloaded).
///
/// Best-effort: a library that fails to fetch is counted and skipped rather than
/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
/// server. Uses `Recursive=true` so a single request per library returns the
/// containers and their playable children.
#[tauri::command]
#[specta::specta]
pub async fn sync_full_catalog(
repository: State<'_, RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
handle: String,
) -> Result<CatalogSyncResult, String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
let mut items_cached = 0usize;
let mut libraries_failed = 0usize;
for library in &libraries {
let opts = GetItemsOptions {
recursive: Some(true),
include_item_types: Some(include_types.clone()),
limit: Some(100_000),
..Default::default()
};
match repo.cache_items_from_server(&library.id, Some(opts)).await {
Ok(items) => {
info!(
"[Catalog] Cached {} items from library '{}'",
items.len(),
library.name
);
items_cached += items.len();
}
Err(e) => {
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
libraries_failed += 1;
}
}
}
// Record the sync time so callers can skip re-syncing too eagerly.
let now = chrono::Utc::now().to_rfc3339();
let upsert = Query::with_params(
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
vec![
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
QueryParam::String(now),
],
);
if let Err(e) = db_service.execute(upsert).await {
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
}
info!(
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
items_cached, libraries_failed
);
Ok(CatalogSyncResult {
items_cached,
libraries_failed,
})
}
/// Report the last-synced timestamp so the UI can show a hint / decide whether
/// to trigger a fresh sync.
#[tauri::command]
#[specta::specta]
pub async fn catalog_sync_status(
db: State<'_, DatabaseWrapper>,
) -> Result<CatalogSyncStatus, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
);
let last_synced_at: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(CatalogSyncStatus { last_synced_at })
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumeQueuedResult {
/// Rows whose stream URL was resolved and are now pump-eligible.
pub resolved: usize,
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
pub failed: usize,
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
target_dir: &str,
resolve: F,
) -> Result<ResumeQueuedResult, String>
where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let rows_query = Query::new(
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
FROM downloads
WHERE status = 'pending' AND stream_url IS NULL",
);
let rows: Vec<(i64, String, String, String)> = db_service
.query_many(rows_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.await
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
}
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
let mut resolved = 0usize;
let mut failed = 0usize;
for (download_id, item_id, media_type, quality) in rows {
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
Some(url) => url,
None => {
failed += 1;
continue;
}
};
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
// concurrent resolver doesn't clobber an already-started row.
let update = Query::with_params(
"UPDATE downloads SET stream_url = ?, target_dir = ?
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir.to_string()),
QueryParam::Int64(download_id),
],
);
match db_service.execute(update).await {
Ok(n) if n > 0 => resolved += 1,
Ok(_) => {} // already resolved by someone else; not a failure
Err(e) => {
warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e);
failed += 1;
}
}
}
Ok(ResumeQueuedResult { resolved, failed })
}
/// Resolve the stream URL for every download row that was queued while offline
/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
/// start. Call this on reconnect.
///
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
/// 'video') via the pure `get_video_download_url` builder using the row's stored
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
/// be resolved are left pending (they retry on the next reconnect).
#[tauri::command]
#[specta::specta]
pub async fn resume_queued_downloads(
repository: State<'_, RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
handle: String,
) -> Result<ResumeQueuedResult, String> {
use crate::repository::MediaRepository;
use crate::repository::HybridRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
// The pump needs a target_dir; use the same storage root the other download
// paths use (the database's parent directory — see `storage_get_path`).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?;
let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
};
// Recover stale downloads: rows left in 'downloading' when the app was killed
// mid-transfer are orphaned — nothing ever restarts them, so they show as
// permanently "downloading". Reset them to 'pending' and clear the stale
// stream_url so they get re-resolved and restarted from scratch below.
let recover_query = Query::new(
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
bytes_downloaded = 0, started_at = NULL \
WHERE status = 'downloading'",
);
match db_service.execute(recover_query).await {
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
Ok(_) => {}
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
}
// Resolve each row's URL against the (now reachable) repository.
let repo_for_resolve = Arc::clone(&repo);
let outcome = resolve_pending_download_urls(
&db_service,
&target_dir,
move |item_id: String, media_type: String, quality: String| {
let repo = Arc::clone(&repo_for_resolve);
async move {
if media_type == "video" {
Some(<HybridRepository as MediaRepository>::get_video_download_url(
repo.as_ref(),
&item_id,
&quality,
None,
))
} else {
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Err(e) => {
warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e);
None
}
}
}
}
},
)
.await
.map_err(|e| e.to_string())?;
let ResumeQueuedResult { resolved, failed } = outcome;
// Kick the pump so the newly-resolved rows actually start.
if resolved > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
}
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
Ok(ResumeQueuedResult { resolved, failed })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::db_service::RusqliteService;
use rusqlite::Connection;
use std::sync::Mutex;
fn test_db() -> Arc<RusqliteService> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
status TEXT NOT NULL,
stream_url TEXT,
target_dir TEXT,
media_type TEXT,
quality_preset TEXT
);
"#,
)
.unwrap();
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
async fn insert_download(
db: &Arc<RusqliteService>,
item_id: &str,
status: &str,
stream_url: Option<&str>,
media_type: Option<&str>,
) {
let q = Query::with_params(
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(status.to_string()),
stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
],
);
db.execute(q).await.unwrap();
}
async fn get_row(db: &Arc<RusqliteService>, item_id: &str) -> (String, Option<String>, Option<String>) {
let q = Query::with_params(
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
);
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.unwrap()
}
#[tokio::test]
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
let db = test_db();
// A row queued offline: pending with no URL yet.
insert_download(&db, "queued-1", "pending", None, None).await;
// An already-resolved pending row: must NOT be touched.
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
// A completed row: irrelevant.
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
})
.await
.unwrap();
assert_eq!(out.resolved, 1);
assert_eq!(out.failed, 0);
// The offline-queued row now has a URL + target dir and stays pending.
let (status, url, target) = get_row(&db, "queued-1").await;
assert_eq!(status, "pending");
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
assert_eq!(target.as_deref(), Some("/data/downloads"));
// The already-resolved row is unchanged (not re-resolved).
let (_s, url2, _t) = get_row(&db, "already").await;
assert_eq!(url2.as_deref(), Some("http://existing/url"));
}
#[tokio::test]
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
let db = test_db();
insert_download(&db, "bad", "pending", None, None).await;
// Resolver returns None (e.g. server lookup failed).
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
.await
.unwrap();
assert_eq!(out.resolved, 0);
assert_eq!(out.failed, 1);
// Still pending with no URL, so a later reconnect can retry it.
let (status, url, _t) = get_row(&db, "bad").await;
assert_eq!(status, "pending");
assert_eq!(url, None);
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}"))
})
.await
.unwrap();
assert_eq!(out.resolved, 1);
let (_s, url, _t) = get_row(&db, "vid-1").await;
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
}
}

View File

@ -2,8 +2,9 @@
#[cfg(test)] #[cfg(test)]
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::State; use tauri::{Manager, State};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use crate::download::{DownloadInfo, DownloadManager}; use crate::download::{DownloadInfo, DownloadManager};
@ -818,9 +819,7 @@ pub async fn start_download(
stream_url: String, stream_url: String,
target_dir: String, target_dir: String,
) -> Result<(), String> { ) -> Result<(), String> {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent; use crate::download::events::DownloadEvent;
use std::path::PathBuf;
use tauri::Emitter; use tauri::Emitter;
debug!("start_download called for download_id: {}", download_id); debug!("start_download called for download_id: {}", download_id);
@ -906,16 +905,27 @@ pub async fn start_download(
} }
}; };
// Update status to downloading and save file_size if we got it // Update status to downloading and save file_size if we got it.
// Also persist the resolved stream URL + target dir so the queue pump can
// restart/resume this download by itself if needed.
let update_query = if let Some(size) = file_size_from_server { let update_query = if let Some(size) = file_size_from_server {
Query::with_params( Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?", "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)], vec![
QueryParam::Int64(size),
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
) )
} else { } else {
Query::with_params( Query::with_params(
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?", "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
vec![QueryParam::Int64(download_id)], vec![
QueryParam::String(stream_url.clone()),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
) )
}; };
@ -936,29 +946,304 @@ pub async fn start_download(
// Build target path // Build target path
let target_path = PathBuf::from(&target_dir).join(&file_path); let target_path = PathBuf::from(&target_dir).join(&file_path);
// Create download task
let task = DownloadTask {
url: stream_url,
target_path: target_path.clone(),
};
// Start download in background
let app_clone = app.clone();
let item_id_clone = item_id.clone();
// Get a clone of the active downloads Arc for unregistering later // Get a clone of the active downloads Arc for unregistering later
let active_downloads = { let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?; let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads() manager.get_active_downloads()
}; };
// Run the worker in the background; on completion/failure it frees the slot
// and pumps the next pending download.
spawn_download_worker(
app.clone(),
download_id,
item_id,
stream_url,
target_path,
active_downloads,
);
Ok(())
}
/// Enqueue a download with its resolved stream URL, then let the queue pump
/// start it (or a higher-priority pending item) when a slot is free.
///
/// Unlike [`start_download`], this never errors when the concurrency limit is
/// reached: the URL is persisted on the row and the pump will pick it up once a
/// slot frees. This is the path bulk operations (album/series/season) use so
/// every queued item eventually downloads without the frontend re-issuing it.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_download(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
app: tauri::AppHandle,
download_id: i64,
stream_url: String,
target_dir: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Persist the resolved URL/dir and mark the row pending so the pump can
// start it. We don't flip to 'downloading' here — the pump owns that.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir),
QueryParam::Int64(download_id),
],
);
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
// Kick the pump: it will start as many pending downloads as there are slots.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Enqueue a batch of already-queued video downloads, resolving each one's
/// transcode URL from the repository using the `quality_preset` stored on the
/// row. Then let the pump start them subject to the concurrency limit.
///
/// This is the bulk video path (series/season): `download_series`/
/// `download_season` insert the rows, then this resolves URLs and enqueues them
/// so they actually start. Resolving server-side avoids round-tripping every
/// episode URL through the frontend.
#[tauri::command]
#[specta::specta]
pub async fn enqueue_video_downloads(
db: State<'_, DatabaseWrapper>,
download_manager: State<'_, DownloadManagerWrapper>,
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
app: tauri::AppHandle,
handle: String,
download_ids: Vec<i64>,
target_dir: String,
) -> Result<(), String> {
use crate::repository::MediaRepository;
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
for download_id in download_ids {
// Read the item + quality preset for this queued download.
let info_query = Query::with_params(
"SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
vec![QueryParam::Int64(download_id)],
);
let (item_id, quality): (String, String) = match db_service
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
.await
{
Ok(row) => row,
Err(e) => {
warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
continue;
}
};
// Build the transcode URL (pure URL builder, no server round-trip).
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
QueryParam::Int64(download_id),
],
);
if let Err(e) = db_service.execute(update_query).await {
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
}
}
// Pump once: starts up to max_concurrent, the rest drain as slots free.
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
pump_download_queue(app, db_service, active_downloads).await;
Ok(())
}
/// Start as many pending downloads as there are free concurrency slots.
///
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
/// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement.
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 { tauri::async_runtime::spawn(async move {
debug!("Download task started for download_id: {}", download_id); debug!("Download task started for download_id: {}", download_id);
let worker = DownloadWorker::new(); let worker = DownloadWorker::new();
// Progress callback that emits events to the frontend // Progress callback that emits events to the frontend
let progress_app = app_clone.clone(); let progress_app = app.clone();
let progress_item_id = item_id_clone.clone(); let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| { let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes let progress = total_bytes
.filter(|&t| t > 0) .filter(|&t| t > 0)
@ -975,25 +1260,60 @@ pub async fn start_download(
let _ = progress_app.emit("download-event", event); let _ = progress_app.emit("download-event", event);
}; };
match worker.download(&task, on_progress).await { let result = worker.download(&task, on_progress).await;
Ok(result) => {
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
// Unregister from download manager // Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() { if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id); active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
} }
// Emit completed event - the frontend will handle state updates // The pump runs downloads in the background, so the terminal status MUST
// be persisted to the DB here — the frontend event handler only writes it
// when that download happens to be loaded in its store, which is not the
// case for auto-pumped rows (or any completion while the downloads page is
// closed). `check_for_local_download` filters on status = 'completed', so a
// missed write leaves finished files unrecognized: albums never show as
// downloaded and playback never switches from the (expiring) stream to the
// local file, cutting tracks off mid-play.
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
return;
}
};
Arc::new(database.service())
};
match result {
Ok(res) => {
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
let file_path = target_path.to_string_lossy().to_string();
let update = Query::with_params(
"UPDATE downloads SET status = 'completed', progress = 1.0, \
bytes_downloaded = ?, file_size = ?, file_path = ?, \
completed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![
QueryParam::Int64(res.bytes_downloaded as i64),
QueryParam::Int64(res.bytes_downloaded as i64),
QueryParam::String(file_path.clone()),
QueryParam::Int64(download_id),
],
);
if let Err(e) = db_service.execute(update).await {
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
}
let completed_event = DownloadEvent::Completed { let completed_event = DownloadEvent::Completed {
download_id, download_id,
item_id: item_id_clone, item_id,
file_path: target_path.to_string_lossy().to_string(), file_path,
}; };
debug!("Emitting completed event: {:?}", completed_event); match app.emit("download-event", completed_event) {
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
match app_clone.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"), Ok(_) => debug!(" Completed event emitted successfully"),
Err(e) => error!(" Completed event emit failed: {:?}", e), Err(e) => error!(" Completed event emit failed: {:?}", e),
} }
@ -1001,28 +1321,32 @@ pub async fn start_download(
Err(e) => { Err(e) => {
error!("Download failed: {:?}", e); error!("Download failed: {:?}", e);
// Unregister from download manager let update = Query::with_params(
if let Ok(mut active) = active_downloads.lock() { "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
active.remove(&download_id); vec![
debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len()); QueryParam::String(e.to_string()),
QueryParam::Int64(download_id),
],
);
if let Err(db_err) = db_service.execute(update).await {
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
} }
// Emit failed event - the frontend will handle state updates
let failed_event = DownloadEvent::Failed { let failed_event = DownloadEvent::Failed {
download_id, download_id,
item_id: item_id_clone.clone(), item_id,
error: e.to_string(), error: e.to_string(),
}; };
debug!("Emitting failed event: {:?}", failed_event); match app.emit("download-event", failed_event) {
match app_clone.emit("download-event", failed_event) {
Ok(_) => debug!(" Failed event emitted successfully"), Ok(_) => debug!(" Failed event emitted successfully"),
Err(e) => error!(" Failed event emit failed: {:?}", e), Err(e) => error!(" Failed event emit failed: {:?}", e),
} }
} }
} }
});
Ok(()) // A slot just freed — start the next pending download (if any).
pump_download_queue(app.clone(), db_service, active_downloads).await;
});
} }
/// Delete a completed download /// Delete a completed download

View File

@ -2,6 +2,7 @@
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 | // TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
// DR-015, DR-017, DR-021, DR-028 // DR-015, DR-017, DR-021, DR-028
pub mod auth; pub mod auth;
pub mod catalog;
pub mod connectivity; pub mod connectivity;
pub mod conversions; pub mod conversions;
pub mod device; pub mod device;
@ -17,6 +18,7 @@ pub mod storage;
pub mod sync; pub mod sync;
pub use auth::*; pub use auth::*;
pub use catalog::*;
pub use connectivity::*; pub use connectivity::*;
pub use conversions::*; pub use conversions::*;
pub use device::*; pub use device::*;

View File

@ -41,12 +41,30 @@ pub fn playback_mode_is_transferring(
pub async fn playback_mode_transfer_to_remote( pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>, manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String, session_id: String,
position: Option<f64>,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!( log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}", "[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
session_id session_id,
position
); );
manager.0.transfer_to_remote(session_id).await manager.0.transfer_to_remote(session_id, position).await
}
/// Set the transferring flag on the playback mode manager.
///
/// Used by the frontend remote->local flow to mark the whole two-step sequence
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
/// casting back to the remote session it's leaving. Always pair `true` with a
/// later `false` (including on error) so the flag can't stick.
#[tauri::command]
#[specta::specta]
pub async fn playback_mode_set_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
transferring: bool,
) -> Result<(), String> {
manager.0.set_transferring(transferring);
Ok(())
} }
/// Transfer playback from remote session back to local device /// Transfer playback from remote session back to local device

View File

@ -224,6 +224,10 @@ pub struct PlayTracksRequest {
pub start_index: usize, pub start_index: usize,
pub shuffle: bool, pub shuffle: bool,
pub context: PlayTracksContext, pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
} }
/// Context information for track playback /// Context information for track playback
@ -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) /// Play a single media item (audio or video)
/// ///
/// Accepts a PlayItemRequest with all optional fields properly defaulted. /// Accepts a PlayItemRequest with all optional fields properly defaulted.
@ -675,6 +738,7 @@ pub async fn player_next(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
debug!("[player_next] Command called from frontend"); debug!("[player_next] Command called from frontend");
@ -693,6 +757,10 @@ pub async fn player_next(
} else { } else {
// Local playback // Local playback
let controller = player.0.lock().await; 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.next().map_err(|e| e.to_string())?;
controller.emit_queue_changed(); controller.emit_queue_changed();
@ -728,6 +796,7 @@ pub async fn player_previous(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>, playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
// Check if we're in remote mode // Check if we're in remote mode
let mode = playback_mode.0.get_mode(); let mode = playback_mode.0.get_mode();
@ -744,6 +813,10 @@ pub async fn player_previous(
} else { } else {
// Local playback // Local playback
let controller = player.0.lock().await; 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.previous().map_err(|e| e.to_string())?;
controller.emit_queue_changed(); 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 /// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command] #[tauri::command]
@ -1254,6 +1380,7 @@ pub async fn player_play_album_track(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayAlbumTrackRequest, request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@ -1295,13 +1422,14 @@ pub async fn player_play_album_track(
info!(" [{}] {} (ID: {})", idx, track.name, track.id); info!(" [{}] {} (ID: {})", idx, track.name, track.id);
} }
// Find the index of the requested track // Validate the requested track exists in the album (its position in the
// final queue is computed after building, since offline tracks are skipped).
info!("Looking for track_id: {}", request.track_id); info!("Looking for track_id: {}", request.track_id);
let start_index = tracks.iter() let album_index = tracks.iter()
.position(|t| t.id == request.track_id) .position(|t| t.id == request.track_id)
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?; .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
info!("Track {} is at index {} in album", request.track_id, start_index); info!("Track {} is at index {} in album", request.track_id, album_index);
// Convert tracks to MediaItems // Convert tracks to MediaItems
let mut media_items = Vec::new(); let mut media_items = Vec::new();
@ -1316,13 +1444,21 @@ pub async fn player_play_album_track(
jellyfin_item_id: Some(jellyfin_id.clone()), jellyfin_item_id: Some(jellyfin_id.clone()),
} }
} else { } else {
// Get stream URL from repository (works online/offline) // Non-downloaded track: needs a stream URL from the server. When the
let stream_url = repository.get_audio_stream_url(&track.id).await // server is unreachable (offline), skip this track rather than failing
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?; // the whole album — downloaded tracks must still be playable.
match repository.get_audio_stream_url(&track.id).await {
MediaSource::Remote { Ok(stream_url) => MediaSource::Remote {
stream_url, stream_url,
jellyfin_item_id: jellyfin_id.clone(), jellyfin_item_id: jellyfin_id.clone(),
},
Err(e) => {
warn!(
"[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
track.name, track.id, e
);
continue;
}
} }
}; };
@ -1362,6 +1498,18 @@ pub async fn player_play_album_track(
media_items.push(media_item); media_items.push(media_item);
} }
if media_items.is_empty() {
return Err("No playable tracks available (offline and nothing downloaded)".to_string());
}
// Tracks with no local download and no reachable server were skipped above,
// so positions shifted. Re-locate the requested track in the built queue.
// If the tapped track itself was skipped, fall back to the first item.
let start_index = media_items
.iter()
.position(|item| item.id == request.track_id)
.unwrap_or(0);
info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index); info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index);
// Handle shuffle before setting queue // Handle shuffle before setting queue
@ -1376,11 +1524,24 @@ pub async fn player_play_album_track(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play the queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
// 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 controller
.play_queue(media_items, start_index) .play_queue(media_items, start_index)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer // Set the queue context for remote transfer
{ {
@ -1422,6 +1583,7 @@ pub async fn player_play_tracks(
session: State<'_, MediaSessionManagerWrapper>, session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>, repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String, repository_handle: String,
request: PlayTracksRequest, request: PlayTracksRequest,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
@ -1529,10 +1691,33 @@ pub async fn player_play_tracks(
session_mgr.start_audio_session(first_item.clone()); session_mgr.start_audio_session(first_item.clone());
} }
// Play queue
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.play_queue(media_items, request.start_index)
// 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())?; .map_err(|e| e.to_string())?;
} else {
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context // Set queue context
{ {
@ -1574,12 +1759,23 @@ pub async fn player_preload_upcoming(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>, db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>, smart_cache: State<'_, SmartCacheWrapper>,
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
app: tauri::AppHandle,
user_id: String, user_id: String,
_download_base_path: String, _download_base_path: String,
) -> Result<PreloadResult, 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())?; 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 // Get cache settings
@ -1617,10 +1813,12 @@ pub async fn player_preload_upcoming(
// Process each upcoming item // Process each upcoming item
for item in upcoming_items { for item in upcoming_items {
// Only process items with Remote source (not already local) // Only process items with Remote source (not already local). The
let jellyfin_id = match &item.source { // source already carries the resolved stream URL — reuse it so the
MediaSource::Remote { jellyfin_item_id, .. } => { // pump can start the download without any extra resolution step.
jellyfin_item_id.clone() let (jellyfin_id, stream_url) = match &item.source {
MediaSource::Remote { jellyfin_item_id, stream_url } => {
(jellyfin_item_id.clone(), stream_url.clone())
} }
MediaSource::Local { .. } => { MediaSource::Local { .. } => {
already_downloaded += 1; already_downloaded += 1;
@ -1632,9 +1830,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( 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![ vec![
QueryParam::String(jellyfin_id.clone()), QueryParam::String(jellyfin_id.clone()),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.clone()),
@ -1651,14 +1853,29 @@ pub async fn player_preload_upcoming(
continue; continue;
} }
// Queue for download with low priority (preload priority = -100) // Queue for download with low priority (preload priority = -100) so
let file_path = format!("{}/{}.mp3", sanitize_filename(&item.album.clone().unwrap_or_default()), sanitize_filename(&item.title)); // 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( 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) "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, ?, ?, ?) VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
ON CONFLICT(item_id, user_id) DO NOTHING", 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![ vec![
QueryParam::String(jellyfin_id), QueryParam::String(jellyfin_id),
QueryParam::String(user_id.clone()), QueryParam::String(user_id.clone()),
@ -1666,6 +1883,8 @@ pub async fn player_preload_upcoming(
QueryParam::String(item.title.clone()), QueryParam::String(item.title.clone()),
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null), item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album.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 +1904,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); info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped);
Ok(PreloadResult { Ok(PreloadResult {
@ -1731,9 +1960,10 @@ pub async fn player_get_cache_config(
#[specta::specta] #[specta::specta]
pub async fn player_configure_jellyfin( pub async fn player_configure_jellyfin(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String, server_url: String,
access_token: String, access_token: String,
_user_id: String, user_id: String,
device_id: String, device_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting"); log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
@ -1744,12 +1974,31 @@ pub async fn player_configure_jellyfin(
device_id, 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; let controller = player.0.lock().await;
controller.set_jellyfin_client(Some(client)); 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(()) Ok(())
} }
@ -1763,13 +2012,105 @@ pub async fn player_disable_jellyfin(
let controller = player.0.lock().await; let controller = player.0.lock().await;
controller.set_jellyfin_client(None); 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(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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 /// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13 /// This reproduces the bug where clicking songs 1-5 always played song 13
#[test] #[test]

View File

@ -347,10 +347,16 @@ pub async fn player_add_tracks_by_ids(
#[specta::specta] #[specta::specta]
pub async fn player_skip_to( pub async fn player_skip_to(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
index: usize, index: usize,
) -> Result<PlayerStatus, String> { ) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await; 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 // Skip to the index and get the item to play
let item = { let item = {
let queue = controller.queue(); let queue = controller.queue();

View File

@ -6,6 +6,7 @@
use tauri::State; use tauri::State;
use super::PlayerStateWrapper; use super::PlayerStateWrapper;
use crate::jellyfin::client::LmsSyncGroup;
/// Play items on a remote Jellyfin session (casting) /// Play items on a remote Jellyfin session (casting)
#[tauri::command] #[tauri::command]
@ -129,3 +130,92 @@ pub async fn remote_session_toggle_mute(
Err("Jellyfin client not configured".to_string()) 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())
}
}

View File

@ -142,6 +142,7 @@ pub async fn player_play_next_episode(
pub async fn player_on_playback_ended( pub async fn player_on_playback_ended(
player: State<'_, PlayerStateWrapper>, player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>, repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item_id: Option<String>, item_id: Option<String>,
repository_handle: Option<String>, repository_handle: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -174,8 +175,14 @@ pub async fn player_on_playback_ended(
// Handle the decision // Handle the decision
match decision { match decision {
AutoplayDecision::Stop => { AutoplayDecision::Stop => {
log::debug!("[Autoplay] Decision: Stop playback"); log::info!("[Autoplay] Decision: Stop playback");
let controller = controller_arc.lock().await; 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() { if let Some(emitter) = controller.event_emitter() {
// Emit StateChanged to idle to clear the current media from mini player // Emit StateChanged to idle to clear the current media from mini player
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop // 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 => { AutoplayDecision::AdvanceToNext => {
log::debug!("[Autoplay] Decision: Advance to next track"); log::info!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue // Advance to next track in queue
let controller = controller_arc.lock().await; 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() { if let Err(e) = controller.next() {
log::error!("[Autoplay] Failed to advance to next track: {}", e); log::error!("[Autoplay] Failed to advance to next track: {}", e);
// Emit PlaybackEnded event on error // Emit PlaybackEnded event on error
@ -235,3 +247,51 @@ pub async fn player_on_playback_ended(
Ok(()) 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(())
}

View File

@ -7,8 +7,9 @@ use crate::utils::lock::MutexSafe;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use log::{debug, error, info}; use log::{debug, error, info, warn};
use tauri::State; use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid; use uuid::Uuid;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
@ -51,6 +52,7 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
#[specta::specta] #[specta::specta]
pub async fn repository_create( pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
player: State<'_, crate::commands::player::PlayerStateWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>, db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>, connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String, server_url: String,
@ -114,6 +116,14 @@ pub async fn repository_create(
manager.0.create(handle.clone(), hybrid); manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully"); 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) Ok(handle)
} }
@ -178,6 +188,23 @@ pub async fn repository_get_item(
.map_err(|e| format!("{:?}", e)) .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 /// Get latest items in a library
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@ -259,6 +286,21 @@ pub async fn repository_get_resume_movies(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get albums the user hasn't listened to recently ("rediscover")
#[tauri::command]
#[specta::specta]
pub async fn repository_get_rediscover_albums(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library /// Get genres for a library
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
@ -273,19 +315,73 @@ pub async fn repository_get_genres(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Tauri event name carrying the merged (cache + server) search results.
pub const SEARCH_EVENT_NAME: &str = "search-event";
/// Payload for the deferred, merged search results pushed to the frontend.
///
/// `request_id` matches the value the frontend passed to `repository_search`,
/// letting it discard updates from queries that have since been superseded.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchUpdateEvent {
pub request_id: u32,
pub result: SearchResult,
}
/// Search for items /// Search for items
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub async fn repository_search( pub async fn repository_search(
app: AppHandle,
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
handle: String, handle: String,
query: String, query: String,
options: Option<SearchOptions>, options: Option<SearchOptions>,
request_id: u32,
) -> Result<SearchResult, String> { ) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?; let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
.search_cache_only(&query, options.clone())
.await .await
.map_err(|e| format!("{:?}", e)) .unwrap_or_else(|e| {
debug!("[Search] Cache search miss/timeout: {:?}", e);
SearchResult {
items: Vec::new(),
total_record_count: 0,
}
});
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
let repo_bg = repo.clone();
let cache_for_merge = cache_result.clone();
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
let event = SearchUpdateEvent {
request_id,
result: merged,
};
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
error!("[Search] Failed to emit search update: {}", e);
}
}
Err(e) => {
// Server failed — the cache results are already on screen, so
// just log. (Offline / unreachable server falls here.)
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
}
}
});
Ok(cache_result)
} }
/// Get playback info for an item /// Get playback info for an item
@ -340,6 +436,49 @@ pub async fn repository_get_audio_stream_url(
.map_err(|e| format!("{:?}", e)) .map_err(|e| format!("{:?}", e))
} }
/// Get Live TV channels (broadcast / IPTV) for browsing
#[tauri::command]
#[specta::specta]
pub async fn repository_get_live_tv_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_live_tv_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Get the root list of plugin "Channels"
#[tauri::command]
#[specta::specta]
pub async fn repository_get_channels(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_channels()
.await
.map_err(|e| format!("{:?}", e))
}
/// Open a live stream for a Live TV channel / live item
#[tauri::command]
#[specta::specta]
pub async fn repository_open_live_stream(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<LiveStreamInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.open_live_stream(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start /// Report playback start
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]

View File

@ -82,6 +82,7 @@ impl ConnectivityReporter {
/// Current reachability as seen by this reporter (shared with the monitor /// Current reachability as seen by this reporter (shared with the monitor
/// and the UI). Useful for callers that want to branch on connectivity. /// 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 { pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable self.status.read().await.is_server_reachable
} }

View File

@ -284,22 +284,35 @@ impl JellyfinClient {
} }
/// Seek on a remote session /// Seek on a remote session
///
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
/// body (as we used to) is silently ignored and the remote never seeks.
pub async fn session_seek( pub async fn session_seek(
&self, &self,
session_id: String, session_id: String,
position_ticks: i64, position_ticks: i64,
) -> Result<(), String> { ) -> Result<(), String> {
#[derive(serde::Serialize)] let url = format!(
#[serde(rename_all = "PascalCase")] "{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
struct SeekRequest { self.config.server_url, session_id, position_ticks
seek_position_ticks: i64, );
let response = self.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
} }
let request = SeekRequest { log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
seek_position_ticks: position_ticks, Ok(())
};
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
} }
/// Send a full GeneralCommand to a remote session. /// Send a full GeneralCommand to a remote session.
@ -371,6 +384,82 @@ impl JellyfinClient {
let sessions = self.get_sessions().await?; let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id))) Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
} }
// --- JellyLMS multi-room sync groups -----------------------------------
//
// The JellyLMS plugin exposes a REST API under `/JellyLms` for grouping LMS
// players ("zones") into synchronized multi-room sync groups. Players are
// addressed by MAC address; JellyTau maps a Jellyfin session to a MAC by
// stripping the `lms-` prefix off the session's device id (see
// LmsDeviceDiscoveryService in the jellyLMS repo, which registers each player
// with deviceId = "lms-{MacAddress}").
/// List current LMS sync groups.
pub async fn lms_get_sync_groups(&self) -> Result<Vec<LmsSyncGroup>, String> {
self.get("/JellyLms/SyncGroups").await
}
/// Fuse LMS zones: create a sync group with `master_mac` as the sync master
/// and `slave_macs` joining it. The master keeps playing; slaves follow.
pub async fn lms_create_sync_group(
&self,
master_mac: &str,
slave_macs: Vec<String>,
) -> Result<(), String> {
let payload = serde_json::json!({
"MasterMac": master_mac,
"SlaveMacs": slave_macs,
});
self.post("/JellyLms/SyncGroups", &payload).await
}
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
async fn delete(&self, endpoint: &str) -> Result<(), String> {
let url = format!("{}{}", self.config.server_url, endpoint);
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
}
Ok(())
}
}
/// An LMS multi-room sync group, as returned by JellyLMS `/JellyLms/SyncGroups`.
///
/// Mirrors the plugin's `SyncGroup` model. The master is the sync source; slaves
/// follow it in lockstep.
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LmsSyncGroup {
#[serde(alias = "MasterMac")]
pub master_mac: String,
#[serde(default, alias = "MasterName")]
pub master_name: String,
#[serde(default, alias = "SlaveMacs")]
pub slave_macs: Vec<String>,
#[serde(default, alias = "SlaveNames")]
pub slave_names: Vec<String>,
} }
/// Default value for supports_remote_control when missing from API /// Default value for supports_remote_control when missing from API

View File

@ -217,6 +217,45 @@ impl HttpClient {
.map_err(|e| format!("Failed to parse JSON: {}", e)) .map_err(|e| format!("Failed to parse JSON: {}", e))
} }
/// Make a GET request and deserialize JSON with a short timeout and no retries.
///
/// Intended for the initial "connect to server" probe on the login screen:
/// a wrong/unreachable URL must fail fast instead of burning through the
/// default 30s-per-attempt timeout and exponential backoff retries.
pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
// Short timeout so an unreachable host fails quickly.
const FAST_TIMEOUT: Duration = Duration::from_secs(10);
let request = self
.client
.get(url)
.timeout(FAST_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// No retry: connection failures on a wrong URL won't succeed on retry,
// they'd only multiply the wait the user sees before an error.
let response = self
.client
.execute(request)
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("HTTP {}: {}", status, error_text));
}
response
.json::<T>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))
}
/// Quick ping to check if a server is reachable (no retry) /// Quick ping to check if a server is reachable (no retry)
pub async fn ping(&self, url: &str) -> bool { pub async fn ping(&self, url: &str) -> bool {
let request = self.client.get(url) let request = self.client.get(url)

View File

@ -23,12 +23,13 @@ use log::{error, info};
use log::warn; use log::warn;
use commands::{ use commands::{
sync_full_catalog, catalog_sync_status, resume_queued_downloads,
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download, cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
download_album, download_item, download_item_and_start, download_video, download_series, download_season, download_album, download_item, download_item_and_start, download_video, download_series, download_season,
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations, get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status, get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download, mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
pin_item, unpin_item, is_item_pinned, pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download, offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status, player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
@ -40,6 +41,8 @@ use commands::{
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer, player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
player_get_autoplay_settings, player_set_autoplay_settings, player_get_autoplay_settings, player_set_autoplay_settings,
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended, 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 // Queue manipulation commands
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids, player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
player_remove_from_queue, player_move_in_queue, player_skip_to, player_remove_from_queue, player_move_in_queue, player_skip_to,
@ -52,11 +55,13 @@ use commands::{
// Remote session control commands // Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute, remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands // Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper, sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands // Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status, playback_mode_get_remote_status,
// Playback reporting commands // Playback reporting commands
playback_reporter_init, playback_reporter_destroy, playback_reporter_init, playback_reporter_destroy,
@ -94,10 +99,12 @@ use commands::{
storage_save_series_audio_preference, storage_get_series_audio_preference, storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands // Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_get_latest_items, repository_get_resume_items, repository_get_item, repository_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_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info, repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url, repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_person, repository_get_items_by_person, repository_get_similar_items,
@ -144,54 +151,126 @@ use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler
/// Handler for media commands from Android MediaSession (lockscreen/notification controls). /// 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")] #[cfg(target_os = "android")]
struct MediaSessionHandler { struct MediaSessionHandler {
player: Arc<TokioMutex<PlayerController>>, 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")] #[cfg(target_os = "android")]
impl MediaCommandHandler for MediaSessionHandler { impl MediaCommandHandler for MediaSessionHandler {
fn on_command(&self, command: &str) { fn on_command(&self, command: &str) {
// Use blocking_lock since this is called from a non-async JNI callback match self.playback_mode.get_mode() {
let controller = self.player.blocking_lock(); playback_mode::PlaybackMode::Remote { session_id } => {
self.handle_remote_command(command, session_id);
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);
} }
_ => self.handle_local_command(command),
} }
} }
} }
@ -355,6 +434,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
// Throw on error so generated `commands.*` return Promise<T> and throw, // Throw on error so generated `commands.*` return Promise<T> and throw,
// matching the existing frontend's invoke() try/catch convention. // matching the existing frontend's invoke() try/catch convention.
.error_handling(tauri_specta::ErrorHandlingMode::Throw) .error_handling(tauri_specta::ErrorHandlingMode::Throw)
.events(tauri_specta::collect_events![
crate::player::events::PlayerStatusEvent
])
.commands(tauri_specta::collect_commands![ .commands(tauri_specta::collect_commands![
// Player commands // Player commands
player_play_item, player_play_item,
@ -397,6 +479,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_cancel_autoplay_countdown, player_cancel_autoplay_countdown,
player_play_next_episode, player_play_next_episode,
player_on_playback_ended, player_on_playback_ended,
player_report_state,
player_report_position,
player_report_media_loaded,
// Preload commands // Preload commands
player_preload_upcoming, player_preload_upcoming,
player_set_cache_config, player_set_cache_config,
@ -413,6 +498,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
remote_session_seek, remote_session_seek,
remote_session_set_volume, remote_session_set_volume,
remote_session_toggle_mute, remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_create_sync_group,
lms_unsync_player,
lms_dissolve_sync_group,
// Session polling commands // Session polling commands
sessions_set_polling_hint, sessions_set_polling_hint,
sessions_poll_now, sessions_poll_now,
@ -423,6 +513,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
playback_mode_transfer_to_remote, playback_mode_transfer_to_remote,
playback_mode_get_remote_status, playback_mode_get_remote_status,
playback_mode_transfer_to_local, playback_mode_transfer_to_local,
playback_mode_set_transferring,
// Playback reporting commands // Playback reporting commands
playback_reporter_init, playback_reporter_init,
playback_reporter_destroy, playback_reporter_destroy,
@ -493,6 +584,11 @@ fn specta_builder() -> Builder<tauri::Wry> {
mark_download_completed, mark_download_completed,
mark_download_failed, mark_download_failed,
start_download, start_download,
enqueue_download,
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
resume_queued_downloads,
get_download_manager_stats, get_download_manager_stats,
set_max_concurrent_downloads, set_max_concurrent_downloads,
get_smart_cache_stats, get_smart_cache_stats,
@ -547,16 +643,21 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_libraries, repository_get_libraries,
repository_get_items, repository_get_items,
repository_get_item, repository_get_item,
repository_jray_actors_at,
repository_get_latest_items, repository_get_latest_items,
repository_get_resume_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_next_up_episodes,
repository_get_recently_played_audio, repository_get_recently_played_audio,
repository_get_resume_movies, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_get_genres,
repository_search, repository_search,
repository_get_playback_info, repository_get_playback_info,
repository_get_video_stream_url, repository_get_video_stream_url,
repository_get_audio_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_start,
repository_report_playback_progress, repository_report_playback_progress,
repository_report_playback_stopped, repository_report_playback_stopped,
@ -585,6 +686,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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// Initialize logger // Initialize logger
@ -592,16 +768,29 @@ pub fn run() {
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.init(); .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` // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app // `.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. // startup, which panics on devices (e.g. Android) where that path doesn't exist.
let builder = specta_builder(); let builder = specta_builder();
let invoke_handler = builder.invoke_handler();
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_os::init())
.setup(|app| { .invoke_handler(invoke_handler)
.setup(move |app| {
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
// listened for on the frontend via the generated bindings.
builder.mount_events(app);
// Initialize database with proper app data directory // Initialize database with proper app data directory
// Check for test mode environment variable first // Check for test mode environment variable first
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") { let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@ -707,16 +896,11 @@ pub fn run() {
let player_arc = Arc::new(TokioMutex::new(player_controller)); 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")] #[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()); player::android::set_player_controller(player_arc.clone());
} }
@ -750,14 +934,26 @@ pub fn run() {
playback_mode_arc.clone(), playback_mode_arc.clone(),
); );
session_poller.set_event_emitter(event_emitter.clone()); session_poller.set_event_emitter(event_emitter.clone());
session_poller.start(); // Note: start() is deferred until after the connectivity monitor is
// created below, so the poller can report reachability from its first
// poll (it drives offline detection + recovery while the user is idle).
let session_poller_arc = Arc::new(session_poller); let session_poller_arc = Arc::new(session_poller);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc); let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
app.manage(session_poller_wrapper); 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")] #[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..."); info!("[INIT] Setting up remote volume handler for Android...");
let handler = Arc::new(RemoteVolumeSessionHandler { let handler = Arc::new(RemoteVolumeSessionHandler {
playback_mode: playback_mode_arc.clone(), playback_mode: playback_mode_arc.clone(),
@ -804,6 +1000,12 @@ pub fn run() {
let mut connectivity_monitor = ConnectivityMonitor::new(http_client); let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
connectivity_monitor.set_app_handle(app.handle().clone()); connectivity_monitor.set_app_handle(app.handle().clone());
// Wire the connectivity reporter into the session poller so its
// continuous background polls drive reachability (offline detection
// + recovery) even when the user isn't browsing, then start it.
session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
session_poller_arc.start();
// Wrap in Arc for sharing with AuthManager // Wrap in Arc for sharing with AuthManager
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor)); let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone()); let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
@ -833,15 +1035,18 @@ pub fn run() {
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager); let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
app.manage(repository_manager_wrapper); 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..."); 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); app.manage(playback_reporter_wrapper);
info!("[INIT] Application setup completed successfully"); info!("[INIT] Application setup completed successfully");
Ok(()) Ok(())
}) })
.invoke_handler(builder.invoke_handler())
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@ -20,6 +20,28 @@ pub enum PlaybackMode {
Idle, Idle,
} }
/// Number of Jellyfin ticks per second (100ns units).
const TICKS_PER_SECOND: f64 = 10_000_000.0;
/// Below this many seconds we treat the position as "at the start" and don't
/// send a resume position, so a fresh track casts from 0 rather than ~0.
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
/// hand to a remote session, or `None` if we're effectively at the start.
///
/// Pure helper so the resume-position math is unit-testable without a remote
/// session or HTTP. The *source* of `position_seconds` matters too: callers
/// must pass the live backend position (`PlayerController::position()`), not the
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
if position_seconds > RESUME_THRESHOLD_SECONDS {
Some((position_seconds * TICKS_PER_SECOND) as i64)
} else {
None
}
}
/// Manages playback mode transfers between local and remote sessions /// Manages playback mode transfers between local and remote sessions
pub struct PlaybackModeManager { pub struct PlaybackModeManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
@ -59,6 +81,19 @@ impl PlaybackModeManager {
self.is_transferring.load(Ordering::Relaxed) self.is_transferring.load(Ordering::Relaxed)
} }
/// Set the transferring flag directly.
///
/// The remote->local transfer is driven from the frontend in two steps
/// (`player_play_tracks` to start local playback, then
/// `playback_mode_transfer_to_local` to stop the remote). The first step's
/// routing depends on this flag: while it's set, `player_play_tracks` plays
/// locally instead of casting back to the remote session. The frontend must
/// raise the flag *before* that first call and lower it when the sequence is
/// done (or aborts), so it can't be left stuck on.
pub fn set_transferring(&self, transferring: bool) {
self.is_transferring.store(transferring, Ordering::Relaxed);
}
/// Send volume command to remote session /// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown" /// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback #[allow(dead_code)] // Called from Android JNI callback
@ -159,7 +194,11 @@ impl PlaybackModeManager {
} }
/// Transfer playback from local device to remote Jellyfin session /// Transfer playback from local device to remote Jellyfin session
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> { pub async fn transfer_to_remote(
&self,
session_id: String,
position_override: Option<f64>,
) -> Result<(), String> {
debug!("[PlaybackMode] transfer_to_remote ENTERED"); debug!("[PlaybackMode] transfer_to_remote ENTERED");
debug!("[PlaybackMode] session_id: {}", session_id); debug!("[PlaybackMode] session_id: {}", session_id);
log::info!( log::info!(
@ -173,7 +212,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner"); debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer // Perform the transfer
let result = self.transfer_to_remote_inner(&session_id).await; let result = self.transfer_to_remote_inner(&session_id, position_override).await;
// Clear transferring flag // Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed); self.is_transferring.store(false, Ordering::Relaxed);
@ -181,12 +220,24 @@ impl PlaybackModeManager {
result result
} }
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> { async fn transfer_to_remote_inner(
&self,
session_id: &str,
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED"); log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id); debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
// position. Capture it so we can resume there and stop it afterwards.
let previous_remote_session = match self.get_mode() {
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
_ => None,
};
// Get current player state and queue context // Get current player state and queue context
let (queue_ids, current_index, position_seconds, queue_context) = { let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
log::info!("[PlaybackMode] Acquiring player controller lock..."); log::info!("[PlaybackMode] Acquiring player controller lock...");
debug!("[PlaybackMode] Acquiring player controller lock..."); debug!("[PlaybackMode] Acquiring player controller lock...");
let player = self.player_controller.lock().await; let player = self.player_controller.lock().await;
@ -195,7 +246,6 @@ impl PlaybackModeManager {
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock_safe();
let state = player.state();
let original_index = queue.current_index().unwrap_or(0); let original_index = queue.current_index().unwrap_or(0);
let items = queue.items(); let items = queue.items();
@ -210,7 +260,19 @@ impl PlaybackModeManager {
} }
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?; let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
let position = state.position().unwrap_or(0.0); // Prefer the frontend-supplied position when available. The backend
// position is unreliable as a transfer source: on Linux, *video* plays
// in the HTML5 <video> element and the MPV backend is never loaded, so
// PlayerController::position() is always 0; only the frontend knows the
// true position. We fall back to the live backend position (correct for
// Linux audio via MPV) when the frontend doesn't pass one.
let position = match position_override {
Some(p) => {
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
p
}
None => player.position(),
};
let context = queue.context().clone(); let context = queue.context().clone();
log::info!( log::info!(
@ -272,12 +334,48 @@ impl PlaybackModeManager {
} }
}; };
// Calculate position in ticks // Remote -> remote switch: take the current track and position from the
let start_position_ticks = if position_seconds > 0.5 { // session we're leaving, since the local player is idle and reports 0.
Some((position_seconds * 10_000_000.0) as i64) if let Some(ref prev_session_id) = previous_remote_session {
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 { } else {
None log::warn!(
}; "[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
now_id,
current_index
);
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
}
}
// Calculate position in ticks (from the live position read above)
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
// Log queue context for debugging (context is tracked but we always send track IDs) // Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context { match &queue_context {
@ -392,6 +490,33 @@ impl PlaybackModeManager {
return Err("Remote session did not load track in time".to_string()); return Err("Remote session did not load track in time".to_string());
} }
// Resume at the right position. We send StartPositionTicks in the play
// command above, but some Jellyfin client/server combinations ignore it
// and start from 0. Now that the track is confirmed loaded, issue an
// explicit seek as well (mirrors how the local resume path works). This
// is the reliable mechanism; StartPositionTicks is best-effort.
if let Some(ticks) = start_position_ticks {
log::info!(
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
ticks
);
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
// Non-fatal: the track is already playing, just not at the
// resume point. Log and continue rather than failing the transfer.
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
}
}
// Remote -> remote switch: stop the session we just left so we don't end
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
}
}
// Stop local playback (queue should remain intact for remote session) // Stop local playback (queue should remain intact for remote session)
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared"); log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
{ {
@ -577,6 +702,23 @@ mod tests {
); );
} }
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
#[test]
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
assert_eq!(start_position_ticks_from_seconds(0.5), None);
// Just past the threshold resumes rather than restarting.
assert!(start_position_ticks_from_seconds(0.6).is_some());
}
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID // Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
mod extract_jellyfin_ids_tests { mod extract_jellyfin_ids_tests {
use crate::player::{MediaItem, MediaSource, MediaType}; use crate::player::{MediaItem, MediaSource, MediaType};

View File

@ -17,7 +17,8 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter}; use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType}; use super::media::{MediaItem, MediaType};
use super::state::PlayerState; 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 /// Global reference to the JavaVM for JNI callbacks
static JAVA_VM: OnceLock<JavaVM> = OnceLock::new(); static JAVA_VM: OnceLock<JavaVM> = OnceLock::new();
@ -584,6 +585,75 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
} else { } else {
log::error!("[Android] WARNING: No event emitter for position update!"); 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. /// 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 // Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime // JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
match controller.lock().await.on_playback_ended().await { // Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => { Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback"); log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend // Emit PlaybackEnded event to frontend
@ -1112,6 +1187,92 @@ pub fn disable_remote_volume() -> Result<(), String> {
Ok(()) 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 /// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> { pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {

View File

@ -10,7 +10,8 @@ use crate::utils::lock::MutexSafe;
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::AppHandle;
use tauri_specta::Event;
use super::{MediaSessionType, SleepTimerMode}; use super::{MediaSessionType, SleepTimerMode};
@ -20,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
/// state machine transitions. /// state machine transitions.
/// ///
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 /// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
// NOTE: fields are intentionally snake_case on the wire. specta generates the
// TypeScript bindings with snake_case field names (it does not apply serde's
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
// serde emit camelCase payloads that no longer match the generated schema —
// tauri-specta then silently drops those events (e.g. state_changed,
// queue_changed never reach the frontend, so the mini player never appears).
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerStatusEvent { pub enum PlayerStatusEvent {
/// Playback position updated (emitted periodically during playback) /// Playback position updated (emitted periodically during playback)
@ -70,6 +78,11 @@ pub enum PlayerStatusEvent {
/// Remaining seconds (for time-based timer) /// Remaining seconds (for time-based timer)
remaining_seconds: u32, 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 /// Show next episode popup with countdown
ShowNextEpisodePopup { ShowNextEpisodePopup {
/// Current episode that just finished /// Current episode that just finished
@ -111,11 +124,25 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin /// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>, 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,
/// Backend-originated control command targeting the active frontend player
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
/// or remote so they can pause/play/seek/stop the webview element.
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
ControlCommand {
/// One of: "play", "pause", "stop", "seek".
action: String,
/// Target position in seconds (only meaningful for "seek").
position: Option<f64>,
},
} }
/// Tauri event name for player status events
pub const PLAYER_EVENT_NAME: &str = "player-event";
/// Trait for emitting player events to the frontend. /// Trait for emitting player events to the frontend.
/// ///
/// This abstraction allows backends to emit events without depending /// This abstraction allows backends to emit events without depending
@ -141,7 +168,9 @@ impl TauriEventEmitter {
impl PlayerEventEmitter for TauriEventEmitter { impl PlayerEventEmitter for TauriEventEmitter {
fn emit(&self, event: PlayerStatusEvent) { fn emit(&self, event: PlayerStatusEvent) {
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) { // Emitted via the tauri-specta Event trait so the payload shape and event
// name match the generated TypeScript bindings (events.playerStatusEvent).
if let Err(e) = Event::emit(&event, &self.app_handle) {
error!("Failed to emit player event: {}", e); error!("Failed to emit player event: {}", e);
} }
} }

View File

@ -46,6 +46,37 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs, 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 crate::utils::lock::MutexSafe;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -134,9 +165,18 @@ impl PlayerController {
self.jellyfin_client.clone() self.jellyfin_client.clone()
} }
/// Configure the playback reporter for dual sync (local DB + server) /// Configure the media repository used for next-episode lookups.
/// Will be called from initialization commands after login ///
#[allow(dead_code)] /// 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>) { pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
let mut reporter_guard = self.playback_reporter.lock().await; let mut reporter_guard = self.playback_reporter.lock().await;
*reporter_guard = reporter; *reporter_guard = reporter;
@ -311,7 +351,27 @@ impl PlayerController {
/// Set the queue and start playing from the specified index /// Set the queue and start playing from the specified index
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> { pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index); self.play_queue_from(items, start_index, None)
}
/// Set the queue and start playing from the specified index, optionally
/// resuming the starting track at `start_position` (seconds).
///
/// The seek happens immediately after load so the backend never audibly
/// starts at 0 and there's no race against a fixed delay. Used when taking
/// over playback from a remote session.
pub fn play_queue_from(
&self,
items: Vec<MediaItem>,
start_index: usize,
start_position: Option<f64>,
) -> Result<(), PlayerError> {
debug!(
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
items.len(),
start_index,
start_position
);
// Reset autoplay counter on manual queue start // Reset autoplay counter on manual queue start
self.reset_autoplay_count(); self.reset_autoplay_count();
@ -324,11 +384,37 @@ impl PlayerController {
// Play the current item (without modifying the queue we just set) // Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() { if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?; self.load_and_play(&item)?;
// Resume from the requested position. Seeking right after load (while
// the backend lock is no longer held) avoids the start-at-0-then-jump
// race that a delayed frontend seek suffers from.
if let Some(position) = start_position {
if position > 0.5 {
self.seek(position)?;
}
}
} }
Ok(()) Ok(())
} }
/// Replace the queue without starting local playback.
///
/// Used when we're controlling a remote session: the tracks play on the
/// remote device, but we keep the local queue in sync so the UI reflects
/// what's playing and a later transfer-to-local has the queue to resume.
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_queue (no local playback): {} items, index {}",
items.len(),
start_index
);
self.reset_autoplay_count();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
Ok(())
}
/// Play/resume playback /// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> { pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play"); debug!("[PlayerController] play");
@ -520,6 +606,13 @@ impl PlayerController {
self.queue.clone() 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 /// Toggle shuffle
pub fn toggle_shuffle(&self) { pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle(); self.queue.lock_safe().toggle_shuffle();
@ -624,6 +717,10 @@ impl PlayerController {
mode: SleepTimerMode::Off, mode: SleepTimerMode::Off,
remaining_seconds: 0, 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); drop(timer);
@ -680,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 ===== // ===== Autoplay Methods =====
/// Get autoplay settings /// Get autoplay settings
@ -796,9 +931,17 @@ impl PlayerController {
let repo = self.repository.lock_safe().clone(); let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id); let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo { 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 { } else {
debug!("[PlayerController] No repository available for audio-path episode lookup"); warn!("[PlayerController] No repository available for episode lookup - cannot autoplay next episode");
None None
}; };
if let Some(next_ep) = next_ep_result { if let Some(next_ep) = next_ep_result {
@ -853,7 +996,7 @@ impl PlayerController {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason); 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 // Check sleep timer state
let timer_mode = { let timer_mode = {
@ -886,8 +1029,17 @@ impl PlayerController {
_ => {} _ => {}
} }
// Fetch next episode for the video that just ended // Fetch next episode for the video that just ended. Degrade lookup
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? { // 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 settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count(); let limit_reached = self.increment_autoplay_count();
@ -937,7 +1089,7 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id { let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(), Some(sid) => sid.clone(),
None => { 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); return Ok(None);
} }
}; };
@ -959,19 +1111,19 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead) // (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items; let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX)); 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 // 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 let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() { if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1]; 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()))); return Ok(Some((current_repo_item, next.clone())));
} else { } else {
debug!("[PlayerController] Current episode is the last in the season"); log::info!("[PlayerController] Current episode is the last in the season");
} }
} else { } 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) Ok(None)
@ -1025,6 +1177,83 @@ impl Default for PlayerController {
mod tests { mod tests {
use super::*; 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] #[test]
fn test_controller_volume_default() { fn test_controller_volume_default() {
let controller = PlayerController::default(); let controller = PlayerController::default();
@ -1333,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] #[test]
fn test_seek_to_zero() { fn test_seek_to_zero() {
let controller = PlayerController::default(); let controller = PlayerController::default();
@ -1645,4 +1912,280 @@ mod tests {
let reason = controller.take_end_reason(); let reason = controller.take_end_reason();
assert!(reason.is_none(), "take_end_reason should clear the state"); 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));
}
} }

View File

@ -62,6 +62,14 @@ impl QueueManager {
&self.items &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 /// Get the current item index
pub fn current_index(&self) -> Option<usize> { pub fn current_index(&self) -> Option<usize> {
self.current_index self.current_index
@ -122,6 +130,18 @@ impl QueueManager {
self.context = context; 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 /// Add items to the queue
pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) { pub fn add(&mut self, items: Vec<MediaItem>, position: AddPosition) {
if items.is_empty() { if items.is_empty() {
@ -559,6 +579,21 @@ mod tests {
assert_eq!(queue.current().unwrap().id, "item_0"); 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 /// Test next track navigation
/// ///
/// @req-test: UR-005 - Control media playback (skip to next track) /// @req-test: UR-005 - Control media playback (skip to next track)

View File

@ -61,7 +61,12 @@ pub enum PlayerState {
} }
impl PlayerState { impl PlayerState {
/// Get the current playback position if available /// Get the current playback position if available.
///
/// Note: this is the position snapshot embedded in the state at the last
/// state transition, not the live backend position. For an up-to-date
/// value use `PlayerController::position()`.
#[allow(dead_code)]
pub fn position(&self) -> Option<f64> { pub fn position(&self) -> Option<f64> {
match self { match self {
PlayerState::Playing { position, .. } => Some(*position), PlayerState::Playing { position, .. } => Some(*position),

View File

@ -45,6 +45,12 @@ impl HybridRepository {
self.online.download_bytes(url).await 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. /// Get video stream URL with optional seeking support.
/// This method is online-only since offline playback uses local file paths. /// This method is online-only since offline playback uses local file paths.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
@ -57,6 +63,101 @@ impl HybridRepository {
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
} }
/// Search only the local SQLite cache (downloaded content).
///
/// Fast (100ms timeout) — used to render instant results before the server
/// responds. Returns an empty result rather than erroring on timeout so the
/// caller can still fall through to the server.
pub async fn search_cache_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline);
let query = query.to_string();
self.cache_with_timeout(async move { offline.search(&query, options).await })
.await
}
/// Fetch a folder's items from the live server and persist them to the
/// offline cache synchronously (unlike `get_items`, which saves in a
/// fire-and-forget background task after a 100ms cache race).
///
/// Used by the full-catalog pre-sync (`sync_full_catalog`) to deterministically
/// walk every library while online so the whole catalog is browsable — greyed
/// out — offline. Returns the items fetched so the caller can recurse into
/// containers. Server-only: errors if unreachable.
pub async fn cache_items_from_server(
&self,
parent_id: &str,
options: Option<GetItemsOptions>,
) -> Result<Vec<MediaItem>, RepoError> {
let result = self.online.get_items(parent_id, options).await?;
if !result.items.is_empty() {
self.offline.save_to_cache(parent_id, &result.items).await?;
}
Ok(result.items)
}
/// Search only the live Jellyfin server (full library).
pub async fn search_server_only(
&self,
query: &str,
options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> {
self.online.search(query, options).await
}
/// Merge cache and server search results into a single de-duplicated list.
///
/// Ordering: local (cached/downloaded) items first, then server-only items
/// appended. On a duplicate `id`, the server's item wins (fresher, more
/// complete metadata) but keeps the local item's earlier position.
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
use std::collections::HashMap;
// Index server items by id so we can (a) override duplicates with the
// server's metadata and (b) know which server items are brand new.
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
for item in server.items {
if !server_by_id.contains_key(&item.id) {
server_order.push(item.id.clone());
}
server_by_id.insert(item.id.clone(), item);
}
let mut items: Vec<MediaItem> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
// Local items first, in their original order. If the server also
// returned this item, take the server's copy (newer metadata).
for local in cache.items {
if !seen.insert(local.id.clone()) {
continue;
}
match server_by_id.remove(&local.id) {
Some(server_item) => items.push(server_item),
None => items.push(local),
}
}
// Then append server-only items, preserving the server's order.
for id in server_order {
if let Some(server_item) = server_by_id.remove(&id) {
if seen.insert(id) {
items.push(server_item);
}
}
}
let total_record_count = items.len();
SearchResult {
items,
total_record_count,
}
}
/// Cache-first query: try cache, fall back to server on miss. /// Cache-first query: try cache, fall back to server on miss.
/// ///
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout) /// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
@ -115,11 +216,41 @@ impl HybridRepository {
#[async_trait] #[async_trait]
impl MediaRepository for HybridRepository { impl MediaRepository for HybridRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> { async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Libraries change infrequently, try cache first with fast timeout // Cache-first (100ms). On a cache hit, refresh the cache from the server
let cache_future = self.cache_with_timeout(self.offline.get_libraries()); // in the background. On a miss, fetch from the server and persist so the
let server_future = self.online.get_libraries(); // list is available on the next (possibly offline) startup.
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
self.parallel_race(cache_future, server_future).await if let Ok(libs) = &cache_result {
if libs.has_content() {
debug!("[HybridRepo] Cache hit for libraries, returning immediately");
let online = Arc::clone(&self.online);
let offline = Arc::clone(&self.offline);
tokio::spawn(async move {
if let Ok(server_libs) = online.get_libraries().await {
if !server_libs.is_empty() {
if let Err(e) = offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Background library cache update failed: {:?}", e);
}
}
}
});
return cache_result;
}
}
// Cache miss — fetch from server and persist for offline use.
match self.online.get_libraries().await {
Ok(server_libs) => {
if !server_libs.is_empty() {
if let Err(e) = self.offline.save_libraries_to_cache(&server_libs).await {
warn!("[HybridRepo] Failed to cache {} libraries: {:?}", server_libs.len(), e);
}
}
Ok(server_libs)
}
Err(e) => cache_result.or(Err(e)),
}
} }
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> { async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
@ -274,23 +405,84 @@ impl MediaRepository for HybridRepository {
self.parallel_race(cache_future, server_future).await self.parallel_race(cache_future, server_future).await
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let offline = Arc::clone(&self.offline); let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online); let online = Arc::clone(&self.online);
let parent_id_str = parent_id.map(|s| s.to_string()); let parent_id_owned = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_str.clone(); let parent_id_clone = parent_id_owned.clone();
let cache_future = self.cache_with_timeout(async move { let cache_future = self.cache_with_timeout(async move {
offline.get_genres(parent_id_str.as_deref()).await offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
}); });
let server_future = async move { let server_future = async move {
online.get_genres(parent_id_clone.as_deref()).await online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
}; };
self.parallel_race(cache_future, server_future).await self.parallel_race(cache_future, server_future).await
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Cache-first (100ms). On a cache hit, refresh the cached genre catalog
// from the server in the background. On a miss, fetch from the server and
// persist so the full genre list is available offline. Mirrors
// get_libraries — NOT parallel_race, whose "any non-empty cache wins"
// rule would pin genres to whatever sparse set the local albums yield.
let parent_id_str = parent_id.map(|s| s.to_string());
let cache_offline = Arc::clone(&self.offline);
let cache_pid = parent_id_str.clone();
let cache_result = self
.cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
.await;
if let Ok(genres) = &cache_result {
if genres.has_content() {
debug!("[HybridRepo] Cache hit for genres, returning immediately");
let online = Arc::clone(&self.online);
let offline = Arc::clone(&self.offline);
let pid = parent_id_str.clone();
tokio::spawn(async move {
if let Ok(server_genres) = online.get_genres(pid.as_deref()).await {
if !server_genres.is_empty() {
if let Err(e) =
offline.save_genres_to_cache(pid.as_deref(), &server_genres).await
{
warn!("[HybridRepo] Background genre cache update failed: {:?}", e);
}
}
}
});
return cache_result;
}
}
// Cache miss — fetch from server and persist for offline use.
match self.online.get_genres(parent_id_str.as_deref()).await {
Ok(server_genres) => {
if !server_genres.is_empty() {
if let Err(e) = self
.offline
.save_genres_to_cache(parent_id_str.as_deref(), &server_genres)
.await
{
warn!(
"[HybridRepo] Failed to cache {} genres: {:?}",
server_genres.len(),
e
);
}
}
Ok(server_genres)
}
Err(e) => cache_result.or(Err(e)),
}
}
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> { async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
let offline = Arc::clone(&self.offline); let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online); let online = Arc::clone(&self.online);
@ -319,6 +511,21 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await 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> { async fn report_playback_start(&self, item_id: &str, position_ticks: i64) -> Result<(), RepoError> {
// Playback reporting goes directly to server // Playback reporting goes directly to server
self.online.report_playback_start(item_id, position_ticks).await self.online.report_playback_start(item_id, position_ticks).await
@ -602,6 +809,14 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!() unimplemented!()
} }
@ -618,6 +833,18 @@ mod tests {
unimplemented!() 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> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!() unimplemented!()
} }
@ -759,6 +986,14 @@ mod tests {
unimplemented!() unimplemented!()
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
unimplemented!() unimplemented!()
} }
@ -775,6 +1010,18 @@ mod tests {
unimplemented!() 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> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
unimplemented!() unimplemented!()
} }
@ -864,6 +1111,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Movie".to_string(), item_type: "Movie".to_string(),
is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: Some("parent-123".to_string()), parent_id: Some("parent-123".to_string()),
library_id: Some("library-456".to_string()), library_id: Some("library-456".to_string()),
@ -871,6 +1119,7 @@ mod tests {
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]), genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
runtime_ticks: Some(7200000000), runtime_ticks: Some(7200000000),
production_year: Some(2024), production_year: Some(2024),
premiere_date: None,
community_rating: Some(8.5), community_rating: Some(8.5),
official_rating: Some("PG-13".to_string()), official_rating: Some("PG-13".to_string()),
primary_image_tag: Some("image-tag-123".to_string()), primary_image_tag: Some("image-tag-123".to_string()),
@ -1058,4 +1307,75 @@ mod tests {
}; };
assert!(result_with_items.has_content(), "Result with items should have content"); assert!(result_with_items.has_content(), "Result with items should have content");
} }
#[test]
fn test_merge_search_local_first_then_server_appended() {
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("c", "Server C"),
create_test_item("d", "Server D"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
// Local items first (in order), then server-only items appended.
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c", "d"]);
assert_eq!(merged.total_record_count, 4);
}
#[test]
fn test_merge_search_dedupes_with_server_winning() {
// "b" appears in both. Server metadata should win, but the item keeps
// its earlier (local) position and is not duplicated.
let cache = SearchResult {
items: vec![
create_test_item("a", "Cached A"),
create_test_item("b", "Cached B"),
],
total_record_count: 2,
};
let server = SearchResult {
items: vec![
create_test_item("b", "Server B (fresher)"),
create_test_item("c", "Server C"),
],
total_record_count: 2,
};
let merged = HybridRepository::merge_search_results(cache, server);
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
assert_eq!(merged.total_record_count, 3);
}
#[test]
fn test_merge_search_handles_empty_sides() {
let only_server = HybridRepository::merge_search_results(
SearchResult { items: vec![], total_record_count: 0 },
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
);
assert_eq!(only_server.items.len(), 1);
assert_eq!(only_server.items[0].id, "x");
let only_cache = HybridRepository::merge_search_results(
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
SearchResult { items: vec![], total_record_count: 0 },
);
assert_eq!(only_cache.items.len(), 1);
assert_eq!(only_cache.items[0].id, "y");
}
} }

View File

@ -4,7 +4,7 @@ pub mod offline;
pub mod hybrid; pub mod hybrid;
pub use types::*; pub use types::*;
pub use online::OnlineRepository; pub use online::{OnlineRepository, JRayActor};
pub use offline::OfflineRepository; pub use offline::OfflineRepository;
pub use hybrid::HybridRepository; pub use hybrid::HybridRepository;
@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>; ) -> Result<Vec<MediaItem>, RepoError>;
/// Get albums the user has played, but not recently ("rediscover" / haven't
/// listened to in a while). Returns albums sorted by least-recently played
/// first, optionally restricted to a parent library.
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError>;
/// Get resume movies /// Get resume movies
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>; async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
@ -108,6 +117,19 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL /// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>; 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 /// Report playback start
/// ///
/// @req: UR-025 - Sync watch history and progress back to Jellyfin /// @req: UR-025 - Sync watch history and progress back to Jellyfin

View File

@ -29,6 +29,7 @@ impl OfflineRepository {
id: item.id.clone(), id: item.id.clone(),
name: item.name, name: item.name,
item_type: item.item_type, item_type: item.item_type,
is_folder: item.is_folder,
server_id: item.server_id, server_id: item.server_id,
parent_id: item.parent_id, parent_id: item.parent_id,
library_id: item.library_id, library_id: item.library_id,
@ -38,6 +39,7 @@ impl OfflineRepository {
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()), .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
runtime_ticks: item.runtime_ticks, runtime_ticks: item.runtime_ticks,
production_year: item.production_year, production_year: item.production_year,
premiere_date: item.premiere_date,
community_rating: item.community_rating, community_rating: item.community_rating,
official_rating: item.official_rating, official_rating: item.official_rating,
primary_image_tag: item.primary_image_tag, primary_image_tag: item.primary_image_tag,
@ -92,6 +94,7 @@ struct CachedItem {
id: String, id: String,
name: String, name: String,
item_type: String, item_type: String,
is_folder: bool,
server_id: String, server_id: String,
parent_id: Option<String>, parent_id: Option<String>,
library_id: Option<String>, library_id: Option<String>,
@ -99,6 +102,7 @@ struct CachedItem {
genres: Option<String>, genres: Option<String>,
runtime_ticks: Option<i64>, runtime_ticks: Option<i64>,
production_year: Option<i32>, production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>, community_rating: Option<f64>,
official_rating: Option<String>, official_rating: Option<String>,
primary_image_tag: 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_id: row.get(20)?,
season_name: row.get(21)?, season_name: row.get(21)?,
parent_index_number: row.get(22)?, 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( let query = Query::with_params(
"INSERT OR REPLACE INTO items ( "INSERT OR REPLACE INTO items (
id, server_id, library_id, parent_id, id, server_id, library_id, parent_id,
name, item_type, overview, name, item_type, is_folder, overview,
genres, series_id, series_name, genres, series_id, series_name,
season_id, season_name, index_number, parent_index_number, season_id, season_name, index_number, parent_index_number,
album_id, album_name, album_artist, artists, album_id, album_name, album_artist, artists,
production_year, runtime_ticks, production_year, premiere_date, runtime_ticks,
primary_image_tag, backdrop_image_tags, primary_image_tag, backdrop_image_tags,
community_rating, official_rating, community_rating, official_rating,
synced_at synced_at
) VALUES ( ) VALUES (
?1, ?2, ?3, ?4, ?1, ?2, ?3, ?4,
?5, ?6, ?7, ?5, ?6, ?7, ?8,
?8, ?9, ?10, ?9, ?10, ?11,
?11, ?12, ?13, ?14, ?12, ?13, ?14, ?15,
?15, ?16, ?17, ?18, ?16, ?17, ?18, ?19,
?19, ?20, ?20, ?21, ?22,
?21, ?22,
?23, ?24, ?23, ?24,
?25 ?25, ?26,
?27
)", )",
vec![ vec![
QueryParam::String(item.id.clone()), QueryParam::String(item.id.clone()),
@ -261,6 +268,7 @@ impl OfflineRepository {
}, },
QueryParam::String(item.name.clone()), QueryParam::String(item.name.clone()),
QueryParam::String(item.item_type.clone()), QueryParam::String(item.item_type.clone()),
QueryParam::Int(if item.is_folder { 1 } else { 0 }),
match &item.overview { match &item.overview {
Some(o) => QueryParam::String(o.clone()), Some(o) => QueryParam::String(o.clone()),
None => QueryParam::Null, None => QueryParam::Null,
@ -313,6 +321,10 @@ impl OfflineRepository {
Some(y) => QueryParam::Int(y), Some(y) => QueryParam::Int(y),
None => QueryParam::Null, None => QueryParam::Null,
}, },
match &item.premiere_date {
Some(d) => QueryParam::String(d.clone()),
None => QueryParam::Null,
},
match item.runtime_ticks { match item.runtime_ticks {
Some(r) => QueryParam::Int64(r), Some(r) => QueryParam::Int64(r),
None => QueryParam::Null, None => QueryParam::Null,
@ -347,6 +359,94 @@ impl OfflineRepository {
Ok(count) Ok(count)
} }
/// Cache the library (view) list from the server into the local database.
/// Called by HybridRepository after a successful online fetch so the list is
/// available offline. Without this, the `libraries` table stays empty and
/// offline startup shows no libraries at all.
pub async fn save_libraries_to_cache(&self, libraries: &[Library]) -> Result<usize, RepoError> {
if libraries.is_empty() {
return Ok(0);
}
let mut count = 0;
for (idx, lib) in libraries.iter().enumerate() {
let query = Query::with_params(
"INSERT OR REPLACE INTO libraries (id, server_id, name, collection_type, image_tag, sort_order, synced_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(lib.id.clone()),
QueryParam::String(self.server_id.clone()),
QueryParam::String(lib.name.clone()),
QueryParam::String(lib.collection_type.clone()),
lib.image_tag.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::Int(idx as i32),
],
);
self.db_service.execute(query).await
.map_err(|e| RepoError::Database { message: e })?;
count += 1;
}
Ok(count)
}
/// Cache the full server genre catalog for a library, so offline (and the
/// hybrid cache-first race) can return the complete list instead of only the
/// genres derivable from locally-cached albums. Replaces the scope's rows
/// wholesale so genres removed on the server don't linger.
pub async fn save_genres_to_cache(
&self,
parent_id: Option<&str>,
genres: &[Genre],
) -> Result<usize, RepoError> {
if genres.is_empty() {
return Ok(0);
}
// library_id is part of the primary key; NULL keys don't de-dupe in
// SQLite, so store the "no library" scope as an empty string.
let library_id = parent_id.unwrap_or("").to_string();
let server_id = self.server_id.clone();
let genres: Vec<(String, String, Option<u32>)> = genres
.iter()
.map(|g| (g.id.clone(), g.name.clone(), g.album_count))
.collect();
let saved = genres.len();
self.db_service
.transaction(move |tx| {
use crate::storage::db_service::{Query, QueryParam};
// Clear the scope's existing genres, then re-insert the fresh set.
tx.execute(Query::with_params(
"DELETE FROM genres WHERE server_id = ? AND library_id = ?",
vec![
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
],
))?;
for (id, name, album_count) in &genres {
tx.execute(Query::with_params(
"INSERT OR REPLACE INTO genres (id, server_id, library_id, name, album_count, synced_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(id.clone()),
QueryParam::String(server_id.clone()),
QueryParam::String(library_id.clone()),
QueryParam::String(name.clone()),
album_count.map(|c| QueryParam::Int(c as i32)).unwrap_or(QueryParam::Null),
],
))?;
}
Ok(())
})
.await
.map_err(|e| RepoError::Database { message: e })?;
Ok(saved)
}
/// Cache playlist items from server into local database /// Cache playlist items from server into local database
/// Called by HybridRepository after fetching from online /// Called by HybridRepository after fetching from online
pub async fn save_playlist_items_to_cache( pub async fn save_playlist_items_to_cache(
@ -401,26 +501,17 @@ impl OfflineRepository {
#[async_trait] #[async_trait]
impl MediaRepository for OfflineRepository { impl MediaRepository for OfflineRepository {
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> { async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
// Only show libraries that have downloaded content // Return every cached library for this server. We deliberately do NOT
// Check both direct children and nested children (e.g., albums inside library) // gate on `items.library_id` here: that column is not populated in the
// cache (the Jellyfin client doesn't parse it), so the old
// `INNER JOIN items i ON i.library_id = l.id` matched nothing and left
// offline startup with zero libraries. Navigating into a library still
// filters to downloaded content via get_items, so listing all cached
// libraries is correct — it's the "local first" list the UI browses.
let query = Query::with_params( let query = Query::with_params(
"SELECT DISTINCT l.id, l.name, l.collection_type, l.image_tag "SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l FROM libraries l
INNER JOIN items i ON i.library_id = l.id
WHERE l.server_id = ? WHERE l.server_id = ?
AND (
-- Direct playable items with downloads
(i.item_type IN ('Audio', 'Movie', 'Episode')
AND EXISTS (SELECT 1 FROM downloads d WHERE d.item_id = i.id AND d.status = 'completed'))
OR
-- Container items with downloaded children
(i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
AND EXISTS (
SELECT 1 FROM items children
INNER JOIN downloads d ON children.id = d.item_id
WHERE children.parent_id = i.id AND d.status = 'completed'
))
)
ORDER BY l.sort_order ASC, l.name ASC", ORDER BY l.sort_order ASC, l.name ASC",
vec![QueryParam::String(self.server_id.clone())], vec![QueryParam::String(self.server_id.clone())],
); );
@ -441,6 +532,13 @@ impl MediaRepository for OfflineRepository {
let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
let start_index = opts.start_index.unwrap_or(0); 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 // Build type filter for optional filtering
let type_filter = if let Some(include_item_types) = &opts.include_item_types { let type_filter = if let Some(include_item_types) = &opts.include_item_types {
if !include_item_types.is_empty() { if !include_item_types.is_empty() {
@ -475,7 +573,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -491,20 +589,41 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, 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.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.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 items i FROM items i
INNER JOIN available_items ai ON i.id = ai.id INNER JOIN available_items ai ON i.id = ai.id
WHERE i.server_id = ? AND i.parent_id = ?{} WHERE i.server_id = ?
ORDER BY i.sort_name ASC, i.name ASC AND (
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
-- When the requested parent is a LIBRARY, there is no per-item
-- link back to it (library_id/parent_id are NULL in the cache),
-- so match every item on the server and let the type filter
-- (e.g. MusicAlbum / Movie / Series) narrow it. This is what
-- makes library landing pages show albums/movies/shows offline.
OR EXISTS (
SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id
)
){}
ORDER BY {}
LIMIT {} OFFSET {}", LIMIT {} OFFSET {}",
type_filter, limit, start_index type_filter, order_by, limit, start_index
); );
// The requested id is compared against every hierarchy-linkage column
// because `parent_id` is not populated for cached items — music tracks
// link to their album via `album_id`, episodes to their season/series
// via `season_id`/`series_id`, and a library parent matches via the
// `libraries` EXISTS clause. See [[offline-libraries-never-cached]].
let query = Query::with_params( let query = Query::with_params(
sql, sql,
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(parent_id.to_string()), QueryParam::String(parent_id.to_string()), // i.parent_id = ?
QueryParam::String(parent_id.to_string()), // i.album_id = ?
QueryParam::String(parent_id.to_string()), // i.season_id = ?
QueryParam::String(parent_id.to_string()), // i.series_id = ?
QueryParam::String(parent_id.to_string()), // libraries.id = ?
], ],
); );
@ -547,7 +666,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -556,7 +675,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, 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.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.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 items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.id = ?", WHERE i.id = ?",
@ -592,7 +711,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -601,7 +720,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, 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.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.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 items i FROM items i
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND i.library_id = ? WHERE i.server_id = ? AND i.library_id = ?
@ -639,7 +758,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@ -664,7 +783,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@ -725,7 +844,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children (Albums) -- Containers with downloaded children (Albums)
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type = 'MusicAlbum' AND i.item_type = 'MusicAlbum'
@ -752,7 +871,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM ranked_plays rp FROM ranked_plays rp
JOIN items i ON rp.display_id = i.id JOIN items i ON rp.display_id = i.id
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@ -778,6 +897,17 @@ impl MediaRepository for OfflineRepository {
Ok(items) Ok(items)
} }
async fn get_rediscover_albums(
&self,
_parent_id: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
// "Rediscover" is a discovery feature over the full server library.
// Offline only holds downloaded items, so there is nothing meaningful
// to surface here; the hybrid repo serves this from the server instead.
Ok(Vec::new())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> { async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12); let limit_val = limit.unwrap_or(12);
@ -789,7 +919,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN user_data ud ON i.id = ud.item_id JOIN user_data ud ON i.id = ud.item_id
INNER JOIN downloads d ON i.id = d.item_id INNER JOIN downloads d ON i.id = d.item_id
@ -819,43 +949,32 @@ impl MediaRepository for OfflineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Extract unique genres from cached items // Read the cached server genre catalog (populated by the hybrid repo via
let mut genre_set = std::collections::HashSet::new(); // save_genres_to_cache). This is the FULL genre list for the library, not
// just the genres derivable from locally-cached albums — so offline keeps
// the same variety the server has. library_id NULL is stored as ''.
let library_id = parent_id.unwrap_or("").to_string();
let (sql, params) = if let Some(pid) = parent_id { let query = Query::with_params(
( "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL",
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()), QueryParam::String(library_id),
] ],
) );
} else {
(
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())]
)
};
let query = Query::with_params(sql, params); let genres: Vec<Genre> = self
.db_service
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0)) .query_many(query, |row| {
Ok(Genre {
id: row.get(0)?,
name: row.get(1)?,
album_count: row.get::<_, Option<i64>>(2)?.map(|c| c as u32),
})
})
.await .await
.map_err(|e| RepoError::Database { message: e })?; .map_err(|e| RepoError::Database { message: e })?;
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
for genre in genres_vec {
genre_set.insert(genre);
}
}
}
let genres = genre_set
.into_iter()
.map(|name| Genre { id: name.clone(), name })
.collect();
Ok(genres) Ok(genres)
} }
@ -897,7 +1016,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -907,7 +1026,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@ -953,6 +1072,21 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline) 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> { async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
// Cannot report to server while offline // Cannot report to server while offline
Err(RepoError::Offline) Err(RepoError::Offline)
@ -1044,6 +1178,7 @@ impl MediaRepository for OfflineRepository {
id: person_data.0, id: person_data.0,
name: person_data.1, name: person_data.1,
item_type: "Person".to_string(), item_type: "Person".to_string(),
is_folder: false,
server_id: self.server_id.clone(), server_id: self.server_id.clone(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
@ -1051,6 +1186,7 @@ impl MediaRepository for OfflineRepository {
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: person_data.3, primary_image_tag: person_data.3,
@ -1094,7 +1230,7 @@ impl MediaRepository for OfflineRepository {
-- Containers with downloaded children -- Containers with downloaded children
SELECT DISTINCT i.id SELECT DISTINCT i.id
FROM items i FROM items i
INNER JOIN items children ON children.parent_id = i.id INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
INNER JOIN downloads d ON children.id = d.item_id INNER JOIN downloads d ON children.id = d.item_id
WHERE d.status = 'completed' WHERE d.status = 'completed'
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder') AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
@ -1104,7 +1240,7 @@ impl MediaRepository for OfflineRepository {
i.community_rating, i.official_rating, i.primary_image_tag, i.community_rating, i.official_rating, i.primary_image_tag,
i.album_id, i.album_name, i.album_artist, i.artists, i.album_id, i.album_name, i.album_artist, i.artists,
i.index_number, i.series_id, i.series_name, i.season_id, i.index_number, i.series_id, i.series_name, i.season_id,
i.season_name, i.parent_index_number i.season_name, i.parent_index_number, i.is_folder, i.premiere_date
FROM items i FROM items i
JOIN item_people ip ON i.id = ip.item_id JOIN item_people ip ON i.id = ip.item_id
INNER JOIN downloaded_items di ON i.id = di.id INNER JOIN downloaded_items di ON i.id = di.id
@ -1215,7 +1351,7 @@ impl MediaRepository for OfflineRepository {
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \ 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.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.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 \ FROM playlist_items pi \
JOIN items i ON pi.item_id = i.id \ JOIN items i ON pi.item_id = i.id \
WHERE pi.playlist_id = ? \ WHERE pi.playlist_id = ? \
@ -1253,6 +1389,8 @@ impl MediaRepository for OfflineRepository {
season_id: row.get(21)?, season_id: row.get(21)?,
season_name: row.get(22)?, season_name: row.get(22)?,
parent_index_number: row.get(23)?, 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)) Ok((entry_id.to_string(), cached))
}) })
@ -1419,10 +1557,12 @@ mod tests {
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
item_type TEXT NOT NULL, item_type TEXT NOT NULL,
is_folder INTEGER DEFAULT 0,
overview TEXT, overview TEXT,
genres TEXT, genres TEXT,
runtime_ticks INTEGER, runtime_ticks INTEGER,
production_year INTEGER, production_year INTEGER,
premiere_date TEXT,
community_rating REAL, community_rating REAL,
official_rating TEXT, official_rating TEXT,
primary_image_tag TEXT, primary_image_tag TEXT,
@ -1474,6 +1614,32 @@ mod tests {
); );
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order); CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE TABLE libraries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
collection_type TEXT,
image_tag TEXT,
sort_order INTEGER DEFAULT 0,
synced_at TEXT
);
CREATE TABLE genres (
id TEXT NOT NULL,
server_id TEXT NOT NULL,
library_id TEXT,
name TEXT NOT NULL,
album_count INTEGER,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, library_id, name)
);
"#).unwrap(); "#).unwrap();
// Insert a test server // Insert a test server
@ -1491,6 +1657,7 @@ mod tests {
id: id.to_string(), id: id.to_string(),
name: name.to_string(), name: name.to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "test-server".to_string(), server_id: "test-server".to_string(),
parent_id: parent_id.map(|s| s.to_string()), parent_id: parent_id.map(|s| s.to_string()),
library_id: None, library_id: None,
@ -1498,6 +1665,7 @@ mod tests {
genres: None, genres: None,
runtime_ticks: None, runtime_ticks: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
primary_image_tag: None, primary_image_tag: None,
@ -1652,6 +1820,196 @@ mod tests {
assert_eq!(result.unwrap(), 3); assert_eq!(result.unwrap(), 3);
} }
/// Regression: a MusicAlbum whose tracks link via `album_id` (and have a
/// NULL `parent_id`, which is how the Jellyfin cache actually stores them)
/// must be recognized as available offline when a track is downloaded.
///
/// Before the fix, `get_item(album_id)` only matched children by
/// `children.parent_id = i.id`, so a fully-downloaded album returned
/// NotFound offline and playback fell through to the (unreachable) server.
#[tokio::test]
async fn test_get_item_album_available_via_album_id_link() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
for sql in [
// Album container (no children by parent_id).
"INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
VALUES ('album-1', 'test-server', 'Hadestown', 'MusicAlbum', NULL, NULL)",
// Track linked to the album ONLY via album_id, parent_id NULL.
"INSERT INTO items (id, server_id, name, item_type, album_id, parent_id) \
VALUES ('track-1', 'test-server', 'Wait For Me', 'Audio', 'album-1', NULL)",
"INSERT INTO downloads (item_id, status) VALUES ('track-1', 'completed')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// The downloaded track itself resolves offline.
assert!(
repo.get_item("track-1").await.is_ok(),
"downloaded track should be available offline"
);
// The album must also resolve offline because it has a downloaded child
// linked by album_id (not parent_id).
let album = repo.get_item("album-1").await;
assert!(
album.is_ok(),
"album with an album_id-linked downloaded track should be available offline, got {:?}",
album.err()
);
assert_eq!(album.unwrap().id, "album-1");
// Browsing into the album (get_items) must return its tracks even though
// they link by album_id and have a NULL parent_id. This is the call
// play_album_track makes to build the queue.
let tracks = repo.get_items("album-1", None).await.unwrap();
assert_eq!(tracks.items.len(), 1, "get_items(album_id) should return the track");
assert_eq!(tracks.items[0].id, "track-1");
}
/// Regression: TV episodes link to their season/series via `season_id` /
/// `series_id` (NOT `parent_id`, which is NULL in the cache). A downloaded
/// episode must make both its Season and Series available offline, and
/// browsing either container must return the episode.
#[tokio::test]
async fn test_get_item_tv_available_via_season_series_link() {
use crate::storage::db_service::DatabaseService;
let db_service = create_test_db();
for sql in [
"INSERT INTO items (id, server_id, name, item_type, parent_id) \
VALUES ('series-1', 'test-server', 'Gilmore Girls', 'Series', NULL)",
"INSERT INTO items (id, server_id, name, item_type, series_id, parent_id) \
VALUES ('season-1', 'test-server', 'Season 1', 'Season', 'series-1', NULL)",
// Episode links to both season and series; parent_id NULL.
"INSERT INTO items (id, server_id, name, item_type, season_id, series_id, parent_id) \
VALUES ('ep-1', 'test-server', 'Pilot', 'Episode', 'season-1', 'series-1', NULL)",
"INSERT INTO downloads (item_id, status) VALUES ('ep-1', 'completed')",
] {
db_service.execute(Query::new(sql)).await.unwrap();
}
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
assert!(repo.get_item("ep-1").await.is_ok(), "downloaded episode available offline");
assert!(
repo.get_item("season-1").await.is_ok(),
"season with a season_id-linked downloaded episode should be available offline"
);
assert!(
repo.get_item("series-1").await.is_ok(),
"series with a series_id-linked downloaded episode should be available offline"
);
// Browsing the season returns the episode.
let season_items = repo.get_items("season-1", None).await.unwrap();
assert!(
season_items.items.iter().any(|i| i.id == "ep-1"),
"get_items(season_id) should return the episode"
);
// Browsing the series returns the episode (via series_id link).
let series_items = repo.get_items("series-1", None).await.unwrap();
assert!(
series_items.items.iter().any(|i| i.id == "ep-1"),
"get_items(series_id) should surface the downloaded episode"
);
}
/// Regression: offline startup must list cached libraries. Previously
/// `get_libraries` joined on `items.library_id` (always NULL in the cache),
/// so it returned nothing offline and the app showed no libraries at all.
/// The list must round-trip through `save_libraries_to_cache`.
#[tokio::test]
async fn test_libraries_cache_roundtrip_available_offline() {
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// Empty cache → no libraries (this is the state that fell through to the
// server and hung offline).
assert!(repo.get_libraries().await.unwrap().is_empty());
// Simulate the online path persisting the server's library list.
let server_libs = vec![
Library { id: "music".into(), name: "Music".into(), collection_type: "music".into(), image_tag: None },
Library { id: "movies".into(), name: "Movies".into(), collection_type: "movies".into(), image_tag: Some("tag".into()) },
];
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(saved, 2);
// Now offline get_libraries returns them without touching the server.
let offline_libs = repo.get_libraries().await.unwrap();
let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, vec!["Music", "Movies"], "cached libraries available offline in sort order");
// Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
repo.save_libraries_to_cache(&server_libs).await.unwrap();
assert_eq!(repo.get_libraries().await.unwrap().len(), 2);
}
/// Regression: the music/TV/movie landing pages lost genre variety because
/// offline `get_genres` derived genres from cached albums only — so the
/// hybrid cache-first race pinned the list to whatever sparse set the local
/// albums yielded instead of the server's full catalog. The full genre list
/// must round-trip through `save_genres_to_cache` and come back scoped by
/// library.
#[tokio::test]
async fn test_genres_cache_roundtrip_scoped_by_library() {
let db_service = create_test_db();
let repo = OfflineRepository::new(
db_service.clone(),
"test-server".to_string(),
"test-user".to_string(),
);
// Empty cache → no genres.
assert!(repo.get_genres(Some("music-lib")).await.unwrap().is_empty());
// Simulate the online path persisting the server's full genre catalog.
let server_genres = vec![
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(42) },
Genre { id: "g2".into(), name: "Jazz".into(), album_count: Some(17) },
Genre { id: "g3".into(), name: "Ambient".into(), album_count: None },
];
let saved = repo.save_genres_to_cache(Some("music-lib"), &server_genres).await.unwrap();
assert_eq!(saved, 3);
// Offline get_genres returns the full set for that library, counts intact.
let mut offline_genres = repo.get_genres(Some("music-lib")).await.unwrap();
offline_genres.sort_by(|a, b| a.name.cmp(&b.name));
let names: Vec<&str> = offline_genres.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, vec!["Ambient", "Jazz", "Rock"]);
let rock = offline_genres.iter().find(|g| g.name == "Rock").unwrap();
assert_eq!(rock.album_count, Some(42));
// Genres are scoped: a different library sees nothing.
assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
// Re-saving replaces the scope's rows (server removed "Jazz").
let updated = vec![
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(50) },
];
repo.save_genres_to_cache(Some("music-lib"), &updated).await.unwrap();
let after = repo.get_genres(Some("music-lib")).await.unwrap();
assert_eq!(after.len(), 1, "stale genres removed on refresh");
assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
}
// ===== Playlist Tests ===== // ===== Playlist Tests =====
/// Helper to seed items into the DB for playlist tests /// Helper to seed items into the DB for playlist tests

View File

@ -11,6 +11,30 @@ use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*}; 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 /// Online repository - fetches data from Jellyfin server via HTTP
pub struct OnlineRepository { pub struct OnlineRepository {
http_client: Arc<HttpClient>, http_client: Arc<HttpClient>,
@ -104,8 +128,33 @@ impl OnlineRepository {
.map_err(|e| format!("Failed to read bytes: {}", e)) .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 /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
// Fast-fail when connectivity is known-offline. Without this every request
// still runs the full HTTP retry/backoff cycle (~7s) before giving up,
// which stalls cache-miss paths and makes offline browsing feel janky.
// The offline recovery probe (connectivity monitor) flips us back to
// reachable the moment the server returns, so this never sticks.
if let Some(reporter) = &self.connectivity {
if !reporter.is_reachable().await {
return Err(RepoError::Offline);
}
}
let result = self.get_json_inner(endpoint).await; let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await; self.report_outcome(&result).await;
result result
@ -363,10 +412,13 @@ struct JellyfinItem {
name: String, name: String,
#[serde(rename = "Type")] #[serde(rename = "Type")]
item_type: String, item_type: String,
#[serde(default)]
is_folder: bool,
parent_id: Option<String>, parent_id: Option<String>,
overview: Option<String>, overview: Option<String>,
genres: Option<Vec<String>>, genres: Option<Vec<String>>,
production_year: Option<i32>, production_year: Option<i32>,
premiere_date: Option<String>,
community_rating: Option<f64>, community_rating: Option<f64>,
official_rating: Option<String>, official_rating: Option<String>,
run_time_ticks: Option<i64>, run_time_ticks: Option<i64>,
@ -450,12 +502,14 @@ impl JellyfinItem {
id: self.id, id: self.id,
name: self.name, name: self.name,
item_type: self.item_type, item_type: self.item_type,
is_folder: self.is_folder,
server_id, server_id,
parent_id: self.parent_id, parent_id: self.parent_id,
library_id: None, // Not provided by Jellyfin API directly library_id: None, // Not provided by Jellyfin API directly
overview: self.overview, overview: self.overview,
genres: self.genres, genres: self.genres,
production_year: self.production_year, production_year: self.production_year,
premiere_date: self.premiere_date,
community_rating: self.community_rating, community_rating: self.community_rating,
official_rating: self.official_rating, official_rating: self.official_rating,
runtime_ticks: self.run_time_ticks, runtime_ticks: self.run_time_ticks,
@ -568,10 +622,22 @@ impl MediaRepository for OnlineRepository {
if let Some(recursive) = opts.recursive { if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive)); endpoint.push_str(&format!("&Recursive={}", recursive));
} }
if let Some(genres) = opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (People only needed in get_item
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
@ -586,7 +652,7 @@ impl MediaRepository for OnlineRepository {
} }
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> { 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 item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.to_media_item(self.user_id.clone()); let media_item = item.to_media_item(self.user_id.clone());
@ -716,12 +782,14 @@ impl MediaRepository for OnlineRepository {
id: album_id, id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()), name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(), item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(), server_id: first_track.server_id.clone(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@ -759,6 +827,32 @@ impl MediaRepository for OnlineRepository {
Ok(final_result) Ok(final_result)
} }
async fn get_rediscover_albums(
&self,
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(12);
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_val
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
.into_iter()
.map(|item| item.to_media_item(self.user_id.clone()))
.collect())
}
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> { async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16); let limit_str = limit.unwrap_or(16);
let endpoint = format!( let endpoint = format!(
@ -775,7 +869,12 @@ impl MediaRepository for OnlineRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let mut endpoint = format!("/Genres?UserId={}", self.user_id); // Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid)); endpoint.push_str(&format!("&ParentId={}", pid));
@ -792,17 +891,41 @@ impl MediaRepository for OnlineRepository {
struct JellyfinGenre { struct JellyfinGenre {
id: String, id: String,
name: String, name: String,
// Which count field Jellyfin populates for a genre under
// Fields=ItemCounts varies by server/version: scoped queries may
// fill AlbumCount, others only ChildCount. Read whichever is
// present so ranking still works. Absent on servers that ignore
// Fields=ItemCounts entirely, so all stay optional.
album_count: Option<u32>,
child_count: Option<u32>,
} }
let response: GenresResponse = self.get_json(&endpoint).await?; let response: GenresResponse = self.get_json(&endpoint).await?;
Ok(response let genres: Vec<Genre> = response
.items .items
.into_iter() .into_iter()
.map(|g| Genre { .map(|g| Genre {
id: g.id, id: g.id,
name: g.name, name: g.name,
album_count: g.album_count.or(g.child_count),
}) })
.collect()) .collect();
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
// see whether the server populates any count field. Remove once known.
log::warn!(
"get_genres: {} genres, {} carry counts. sample: {:?}",
genres.len(),
with_counts,
genres
.iter()
.take(8)
.map(|g| (g.name.as_str(), g.album_count))
.collect::<Vec<_>>()
);
Ok(genres)
} }
async fn search( async fn search(
@ -811,19 +934,30 @@ impl MediaRepository for OnlineRepository {
options: Option<SearchOptions>, options: Option<SearchOptions>,
) -> Result<SearchResult, RepoError> { ) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50); let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!( let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true", "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id, query, limit self.user_id,
urlencoding::encode(query),
limit
); );
if let Some(opts) = options { if let Some(opts) = options {
if let Some(types) = opts.include_item_types { if let Some(types) = opts.include_item_types {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(","))); let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
} }
} }
// Request image fields for list views (People only needed in get_item detail view) // Request image fields for list views (plus Genres so cached items
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags"); // carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult { Ok(SearchResult {
@ -1061,6 +1195,105 @@ impl MediaRepository for OnlineRepository {
Ok(url) 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( async fn report_playback_start(
&self, &self,
item_id: &str, item_id: &str,
@ -1191,12 +1424,43 @@ impl MediaRepository for OnlineRepository {
quality: &str, quality: &str,
media_source_id: Option<&str>, media_source_id: Option<&str>,
) -> String { ) -> String {
let mut url = format!("{}/Videos/{}/download", self.server_url, item_id); // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
// available (returns 404 on many server configs), which silently broke
// every movie/TV download. Use the progressive `stream.mp4` endpoint
// instead — it is always present and supports HTTP Range, which the
// download worker relies on for resume.
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
let mut params = vec![format!("api_key={}", self.access_token)]; let mut params = vec![format!("api_key={}", self.access_token)];
// Add quality parameter if not "original" // Map the frontend quality preset to concrete transcode params. For
if quality != "original" { // "original" we request a direct static copy (no transcode) which is
params.push(format!("quality={}", quality)); // byte-range resumable; other presets ask the server to transcode.
match quality {
"high" => {
params.push("videoBitrate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitrate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"medium" => {
params.push("videoBitrate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitrate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
"low" => {
params.push("videoBitrate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitrate=128000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
}
// "original" (and any unknown value) → direct, resumable copy.
_ => {
params.push("Static=true".to_string());
}
} }
// Add media source ID if provided // Add media source ID if provided
@ -1204,10 +1468,8 @@ impl MediaRepository for OnlineRepository {
params.push(format!("mediaSourceId={}", source_id)); params.push(format!("mediaSourceId={}", source_id));
} }
if !params.is_empty() {
url.push('?'); url.push('?');
url.push_str(&params.join("&")); url.push_str(&params.join("&"));
}
url url
} }
@ -1537,6 +1799,25 @@ mod tests {
} }
} }
/// When connectivity is known-offline, `get_json` must fast-fail with
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
/// This is what keeps offline browsing snappy. `test.server.com` is
/// unroutable, so if the guard were absent this would hang on retries; the
/// assertion returning promptly with `Offline` proves the short-circuit.
#[tokio::test]
async fn test_get_json_fast_fails_when_offline() {
let (repo, reporter) = create_test_repository_with_connectivity();
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
assert!(
matches!(result, Err(RepoError::Offline)),
"known-offline get_json should return Offline immediately, got {:?}",
result
);
}
/// A network error routes through the debounced path. A single failure stays /// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed). /// online (debounce window not yet elapsed).
#[tokio::test] #[tokio::test]
@ -1665,6 +1946,74 @@ mod tests {
assert_eq!(tags.primary(), None); assert_eq!(tags.primary(), None);
} }
// ===== Video download URL (real impl) =====
//
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
// not a mock. A prior mock in online_integration_test.rs used the correct
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
// which returns 404 on real servers and silently broke every movie/TV
// download. Assert the real builder targets the resumable stream endpoint.
//
// @req-test: DR-013 - Repository pattern for online/offline data access
#[test]
fn test_video_download_url_uses_stream_not_download_endpoint() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// Must NOT use the /download endpoint (404 on real servers).
assert!(
!url.contains("/download"),
"download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
);
// Must use the progressive, range-resumable stream endpoint.
assert!(
url.contains("/Videos/item123/stream.mp4"),
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
);
assert!(url.contains("api_key=test-access-token"), "url: {url}");
}
#[test]
fn test_video_download_url_original_is_static_direct_copy() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", None);
// "original" must request a direct static copy (byte-range resumable),
// with no transcode params.
assert!(url.contains("Static=true"), "url: {url}");
assert!(!url.contains("videoBitrate"), "original must not transcode: {url}");
assert!(!url.contains("maxHeight"), "original must not transcode: {url}");
}
#[test]
fn test_video_download_url_quality_presets_transcode() {
let repo = create_test_repository();
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
let url = repo.get_video_download_url("item123", quality, None);
assert!(
url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}"
);
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {url}");
assert!(
url.contains(&format!("maxHeight={height}")),
"{quality} must cap height at {height}: {url}"
);
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
// Transcoded presets must not also ask for a static copy.
assert!(!url.contains("Static=true"), "{quality} must not be Static: {url}");
}
}
#[test]
fn test_video_download_url_passes_media_source_id() {
let repo = create_test_repository();
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
}
#[test] #[test]
fn test_jellyfin_item_deserialize_with_image_tags() { fn test_jellyfin_item_deserialize_with_image_tags() {
// Test full JellyfinItem deserialization with ImageTags // Test full JellyfinItem deserialization with ImageTags
@ -1768,4 +2117,43 @@ mod tests {
assert_eq!(response.items[0].id, "item1"); assert_eq!(response.items[0].id, "item1");
assert_eq!(response.items[1].id, "item2"); assert_eq!(response.items[1].id, "item2");
} }
#[test]
fn test_search_term_is_url_encoded() {
// A multi-word query (and one with a reserved character) must be
// percent-encoded before being placed in the SearchTerm query param,
// otherwise the request URL is malformed and search returns nothing.
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
}
#[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, "");
}
} }

View File

@ -104,6 +104,10 @@ pub struct MediaItem {
pub name: String, pub name: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub item_type: String, 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, pub server_id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>, pub parent_id: Option<String>,
@ -115,6 +119,10 @@ pub struct MediaItem {
pub genres: Option<Vec<String>>, pub genres: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub production_year: Option<i32>, 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub community_rating: Option<f64>, pub community_rating: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -249,12 +257,30 @@ pub struct PlaybackInfo {
pub needs_transcoding: bool, 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 /// Genre
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Genre { pub struct Genre {
pub id: String, pub id: String,
pub name: String, pub name: String,
/// Number of albums tagged with this genre, when the backend can supply it
/// (online only). Lets the frontend rank/pick genres without probing each
/// one. `None` when unknown (e.g. offline).
pub album_count: Option<u32>,
} }
/// Image type /// Image type
@ -477,12 +503,14 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Test".to_string(), name: "Test".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@ -616,12 +644,14 @@ mod tests {
id: "track1".to_string(), id: "track1".to_string(),
name: "Test Track".to_string(), name: "Test Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "server1".to_string(), server_id: "server1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,
@ -675,12 +705,14 @@ mod tests {
id: "1".to_string(), id: "1".to_string(),
name: "Track".to_string(), name: "Track".to_string(),
item_type: "Audio".to_string(), item_type: "Audio".to_string(),
is_folder: false,
server_id: "s1".to_string(), server_id: "s1".to_string(),
parent_id: None, parent_id: None,
library_id: None, library_id: None,
overview: None, overview: None,
genres: None, genres: None,
production_year: None, production_year: None,
premiere_date: None,
community_rating: None, community_rating: None,
official_rating: None, official_rating: None,
runtime_ticks: None, runtime_ticks: None,

View File

@ -32,6 +32,13 @@ pub struct SessionPollerManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>, jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>, playback_mode_manager: Arc<PlaybackModeManager>,
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>, event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
/// Optional connectivity reporter. The session poller is the one piece of
/// server traffic that runs continuously even when the user is idle (not
/// browsing the library), so feeding its poll outcomes into the reporter is
/// what lets the app detect going offline — and, crucially, recover when the
/// server returns — without any user interaction. Repository traffic alone
/// can't do this because it only happens while browsing.
connectivity_reporter: Arc<Mutex<Option<crate::connectivity::ConnectivityReporter>>>,
// Polling state // Polling state
is_running: Arc<AtomicBool>, is_running: Arc<AtomicBool>,
@ -52,6 +59,7 @@ impl SessionPollerManager {
jellyfin_client, jellyfin_client,
playback_mode_manager, playback_mode_manager,
event_emitter: Arc::new(Mutex::new(None)), event_emitter: Arc::new(Mutex::new(None)),
connectivity_reporter: Arc::new(Mutex::new(None)),
is_running: Arc::new(AtomicBool::new(false)), is_running: Arc::new(AtomicBool::new(false)),
current_hint: Arc::new(RwLock::new(PollingHint::Normal)), current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
@ -64,6 +72,13 @@ impl SessionPollerManager {
*self.event_emitter.lock_safe() = Some(emitter); *self.event_emitter.lock_safe() = Some(emitter);
} }
/// Wire the connectivity reporter so each poll outcome updates reachability.
/// A successful poll recovers the app to online instantly; sustained poll
/// failures flip it offline (subject to the reporter's debounce window).
pub fn set_connectivity_reporter(&self, reporter: crate::connectivity::ConnectivityReporter) {
*self.connectivity_reporter.lock_safe() = Some(reporter);
}
/// Start the background polling thread /// Start the background polling thread
pub fn start(&self) { pub fn start(&self) {
if self.is_running.swap(true, Ordering::Relaxed) { if self.is_running.swap(true, Ordering::Relaxed) {
@ -77,6 +92,7 @@ impl SessionPollerManager {
let client = self.jellyfin_client.clone(); let client = self.jellyfin_client.clone();
let mode_manager = self.playback_mode_manager.clone(); let mode_manager = self.playback_mode_manager.clone();
let emitter = self.event_emitter.clone(); let emitter = self.event_emitter.clone();
let connectivity_reporter = self.connectivity_reporter.clone();
let is_running = self.is_running.clone(); let is_running = self.is_running.clone();
let hint = self.current_hint.clone(); let hint = self.current_hint.clone();
let interval_ms = self.current_interval_ms.clone(); let interval_ms = self.current_interval_ms.clone();
@ -96,22 +112,51 @@ impl SessionPollerManager {
debug!("[SessionPoller] Polling with interval: {}ms", new_interval); debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
// Fetch sessions // Fetch sessions. `had_client` distinguishes "server didn't
let sessions_result = rt.block_on(async { // answer" from "no client configured" so we only feed real
// request outcomes into the connectivity reporter.
let (sessions_result, had_client) = rt.block_on(async {
let client_opt = client.lock_safe().clone(); let client_opt = client.lock_safe().clone();
match client_opt { match client_opt {
Some(c) => c.get_sessions().await, Some(c) => (c.get_sessions().await, true),
None => { None => {
debug!("[SessionPoller] Jellyfin client not configured, skipping poll"); debug!("[SessionPoller] Jellyfin client not configured, skipping poll");
Ok(Vec::new()) (Ok(Vec::new()), false)
} }
} }
}); });
// Drive the connectivity reporter from this poll's outcome. This
// is what recovers the app to online when the server returns
// while the user is idle, and detects going offline when no
// library browsing is happening. See connectivity/mod.rs.
if had_client {
if let Some(reporter) = connectivity_reporter.lock_safe().clone() {
rt.block_on(async {
match &sessions_result {
Ok(_) => reporter.report_success().await,
Err(e) => reporter.report_network_failure(Some(e.clone())).await,
}
});
}
}
// Emit event if successful // Emit event if successful
match sessions_result { match sessions_result {
Ok(sessions) => { Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len()); 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() { if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions, sessions,
@ -150,6 +195,66 @@ impl SessionPollerManager {
*self.current_hint.write_safe() = hint; *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 /// Calculate polling interval based on mode and hint
fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 { fn calculate_interval(mode: &PlaybackMode, hint: PollingHint) -> u64 {
match hint { match hint {

View File

@ -201,6 +201,7 @@ mod tests {
"thumbnails", "thumbnails",
"playlists", "playlists",
"playlist_items", "playlist_items",
"genres",
]; ];
for table in expected_tables { for table in expected_tables {

View File

@ -21,6 +21,9 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("014_series_audio_preferences", MIGRATION_014), ("014_series_audio_preferences", MIGRATION_014),
("015_device_id", MIGRATION_015), ("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016), ("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
("019_genres_cache", MIGRATION_019),
]; ];
/// Initial schema migration /// Initial schema migration
@ -667,3 +670,47 @@ const MIGRATION_016: &str = r#"
ALTER TABLE user_player_settings ALTER TABLE user_player_settings
ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0; ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
"#; "#;
/// Migration to persist the resolved stream URL and target directory on each
/// download row. This lets the backend queue pump start a pending download by
/// itself (replaying the stored URL) once a concurrency slot frees up, instead
/// of relying on the frontend to re-issue every queued item.
const MIGRATION_017: &str = r#"
-- Resolved download source URL and on-disk target directory, captured when the
-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
-- URL simply won't be auto-started by the pump.
ALTER TABLE downloads ADD COLUMN stream_url TEXT;
ALTER TABLE downloads ADD COLUMN target_dir TEXT;
"#;
/// 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;
"#;
/// Migration to cache the full server genre catalog. Previously genres were
/// derived on the fly from cached albums, which meant offline (and the hybrid
/// cache-first race) only ever saw genres for the handful of locally-cached
/// albums — collapsing the variety on the music/TV/movie landing pages. This
/// table stores the complete genre list per library so offline has the real
/// catalog and cache-first routing returns the right thing.
const MIGRATION_019: &str = r#"
CREATE TABLE IF NOT EXISTS genres (
id TEXT NOT NULL,
server_id TEXT NOT NULL,
library_id TEXT,
name TEXT NOT NULL,
album_count INTEGER,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, library_id, name)
);
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
"#;

View File

@ -64,7 +64,8 @@ impl ThumbnailCache {
image_type: &str, image_type: &str,
tag: &str, tag: &str,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
let query = Query::with_params( // Primary lookup: exact (item_id, image_type, tag) match.
let exact = Query::with_params(
"SELECT file_path FROM thumbnails "SELECT file_path FROM thumbnails
WHERE item_id = ? AND image_type = ? AND image_tag = ?", WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
@ -74,12 +75,60 @@ impl ThumbnailCache {
], ],
); );
let path_str: String = db.query_optional(query, |row| row.get(0)).await.ok()??; if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
let path = PathBuf::from(&path_str); let path = PathBuf::from(&path_str);
if path.exists() { if path.exists() {
// Update last_accessed for LRU tracking self.touch(&db, item_id, image_type, Some(tag)).await;
let update_query = Query::with_params( return Some(path);
}
// File gone — drop the stale row and fall through to the tag-agnostic
// lookup below (another cached image for this item may still exist).
let _ = db.execute(Query::with_params(
"DELETE FROM thumbnails WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
],
)).await;
}
// Fallback: any cached image for this item + type, newest first. The
// `image_tag` is a cache-busting version, and callers don't always pass
// the same tag the image was cached under — e.g. the mini player asks for
// the album image using the *track's* primary_image_tag. Ignoring the tag
// here lets those still resolve offline instead of hitting the server.
let any_tag = Query::with_params(
"SELECT file_path FROM thumbnails
WHERE item_id = ? AND image_type = ?
ORDER BY cached_at DESC LIMIT 1",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()),
],
);
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, None).await;
Some(path)
} else {
None
}
}
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
/// that exact row; when `None`, touch every row for the item + type.
async fn touch(
&self,
db: &Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: Option<&str>,
) {
let query = match tag {
Some(tag) => Query::with_params(
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
WHERE item_id = ? AND image_type = ? AND image_tag = ?", WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
@ -87,23 +136,17 @@ impl ThumbnailCache {
QueryParam::String(image_type.to_string()), QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()), QueryParam::String(tag.to_string()),
], ],
); ),
let _ = db.execute(update_query).await; None => Query::with_params(
Some(path) "UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
} else { WHERE item_id = ? AND image_type = ?",
// Clean up stale database entry
let delete_query = Query::with_params(
"DELETE FROM thumbnails
WHERE item_id = ? AND image_type = ? AND image_tag = ?",
vec![ vec![
QueryParam::String(item_id.to_string()), QueryParam::String(item_id.to_string()),
QueryParam::String(image_type.to_string()), QueryParam::String(image_type.to_string()),
QueryParam::String(tag.to_string()),
], ],
); ),
let _ = db.execute(delete_query).await; };
None let _ = db.execute(query).await;
}
} }
/// Save thumbnail to cache /// Save thumbnail to cache

View File

@ -197,6 +197,25 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> { async playerOnPlaybackEnded(itemId: string | null, repositoryHandle: string | null) : Promise<null> {
return await TAURI_INVOKE("player_on_playback_ended", { itemId, repositoryHandle }); 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 * Preload upcoming tracks from the queue
* This queues background downloads for the next N tracks that aren't already downloaded * 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> { async remoteSessionToggleMute(sessionId: string) : Promise<null> {
return await TAURI_INVOKE("remote_session_toggle_mute", { sessionId }); 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 * 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 * Transfer playback from local device to a remote Jellyfin session
*/ */
async playbackModeTransferToRemote(sessionId: string) : Promise<null> { async playbackModeTransferToRemote(sessionId: string, position: number | null) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId }); return await TAURI_INVOKE("playback_mode_transfer_to_remote", { sessionId, position });
}, },
/** /**
* Get remote session status (for polling position/duration) * Get remote session status (for polling position/duration)
@ -322,6 +366,17 @@ async playbackModeGetRemoteStatus() : Promise<RemoteSessionStatus> {
async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> { async playbackModeTransferToLocal(currentItemId: string, positionTicks: number) : Promise<null> {
return await TAURI_INVOKE("playback_mode_transfer_to_local", { currentItemId, positionTicks }); 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) * Initialize playback reporter (called after login)
*/ */
@ -726,6 +781,63 @@ async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<nul
async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> { async startDownload(downloadId: number, streamUrl: string, targetDir: string) : Promise<null> {
return await TAURI_INVOKE("start_download", { downloadId, streamUrl, targetDir }); 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 });
},
/**
* Walk every library on the server and persist all items to the offline cache
* so the full catalog is browsable offline (greyed out when not downloaded).
*
* Best-effort: a library that fails to fetch is counted and skipped rather than
* aborting the whole sync. Runs libraries sequentially to avoid hammering the
* server. Uses `Recursive=true` so a single request per library returns the
* containers and their playable children.
*/
async syncFullCatalog(handle: string) : Promise<CatalogSyncResult> {
return await TAURI_INVOKE("sync_full_catalog", { handle });
},
/**
* Report the last-synced timestamp so the UI can show a hint / decide whether
* to trigger a fresh sync.
*/
async catalogSyncStatus() : Promise<CatalogSyncStatus> {
return await TAURI_INVOKE("catalog_sync_status");
},
/**
* Resolve the stream URL for every download row that was queued while offline
* (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
* start. Call this on reconnect.
*
* Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
* 'video') via the pure `get_video_download_url` builder using the row's stored
* `quality_preset` mirroring `enqueue_video_downloads`. Rows whose URL can't
* be resolved are left pending (they retry on the next reconnect).
*/
async resumeQueuedDownloads(handle: string) : Promise<ResumeQueuedResult> {
return await TAURI_INVOKE("resume_queued_downloads", { handle });
},
/** /**
* Get download manager statistics * Get download manager statistics
*/ */
@ -1007,6 +1119,14 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> { async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
return await TAURI_INVOKE("repository_get_item", { handle, itemId }); 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 * Get latest items in a library
*/ */
@ -1037,6 +1157,12 @@ async repositoryGetRecentlyPlayedAudio(handle: string, limit: number | null) : P
async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> { async repositoryGetResumeMovies(handle: string, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_resume_movies", { handle, limit }); 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 * Get genres for a library
*/ */
@ -1046,8 +1172,8 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
/** /**
* Search for items * Search for items
*/ */
async repositorySearch(handle: string, query: string, options: SearchOptions | null) : Promise<SearchResult> { async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options }); return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
}, },
/** /**
* Get playback info for an item * Get playback info for an item
@ -1067,6 +1193,24 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> { async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId }); 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 * Report playback start
*/ */
@ -1245,6 +1389,11 @@ async convertPercentToVolume(percent: number) : Promise<number> {
/** user-defined events **/ /** user-defined events **/
export const events = __makeEvents__<{
playerStatusEvent: PlayerStatusEvent
}>({
playerStatusEvent: "player-status-event"
})
/** user-defined constants **/ /** user-defined constants **/
@ -1388,6 +1537,20 @@ export type CachedLibrary = { id: string; serverId: string; name: string; collec
* Cached person info returned to frontend * Cached person info returned to frontend
*/ */
export type CachedPerson = { id: string; serverId: string; name: string; overview: string | null; primaryImageTag: string | null; premiereDate: string | null; endDate: string | null } export type CachedPerson = { id: string; serverId: string; name: string; overview: string | null; primaryImageTag: string | null; premiereDate: string | null; endDate: string | null }
export type CatalogSyncResult = {
/**
* Total items persisted to the offline cache across all libraries.
*/
itemsCached: number;
/**
* Libraries that failed to sync (e.g. server hiccup); best-effort.
*/
librariesFailed: number }
export type CatalogSyncStatus = {
/**
* RFC-3339 timestamp of the last successful sync, if any.
*/
lastSyncedAt: string | null }
/** /**
* Connectivity status * Connectivity status
*/ */
@ -1440,7 +1603,13 @@ export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStat
/** /**
* Genre * 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) * Request to get an image URL (with caching)
*/ */
@ -1457,14 +1626,47 @@ export type ImageOptions = { maxWidth?: number | null; maxHeight?: number | null
* Image type * Image type
*/ */
export type ImageType = "Primary" | "Backdrop" | "Banner" | "Thumb" | "Logo" 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) * Library (media collection)
*/ */
export type Library = { id: string; name: string; collectionType: string; imageTag?: string | null } 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 * 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 * Media session type tracking the high-level playback context
*/ */
@ -1579,7 +1781,12 @@ export type PlayTracksContext = { type: "playlist"; playlistId: string; playlist
/** /**
* Request to play tracks by ID (backend fetches metadata) * 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 * Playback information
*/ */
@ -1760,6 +1967,90 @@ mergedIsPlaying: boolean;
* Volume from either local player or remote session (0-1 normalized) * Volume from either local player or remote session (0-1 normalized)
*/ */
mergedVolume: number } 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" } |
/**
* Backend-originated control command targeting the active frontend player
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
* or remote so they can pause/play/seek/stop the webview element.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null }
/** /**
* Result of creating a playlist * Result of creating a playlist
* *
@ -1776,7 +2067,17 @@ export type PlaylistEntry =
/** /**
* The underlying media item * 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) * The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
*/ */
@ -1811,6 +2112,15 @@ export type RemoteSessionStatus = { position: number; duration: number | null; i
* TRACES: UR-005 | DR-005 * TRACES: UR-005 | DR-005
*/ */
export type RepeatMode = "off" | "all" | "one" export type RepeatMode = "off" | "all" | "one"
export type ResumeQueuedResult = {
/**
* Rows whose stream URL was resolved and are now pump-eligible.
*/
resolved: number;
/**
* Rows that couldn't be resolved (item metadata / URL lookup failed).
*/
failed: number }
/** /**
* Options for search queries * Options for search queries
*/ */

View File

@ -334,6 +334,7 @@ describe("RepositoryClient", () => {
includeItemTypes: ["Audio"], includeItemTypes: ["Audio"],
limit: 100, limit: 100,
}, },
requestId: 0,
}); });
}); });
}); });

View File

@ -3,6 +3,7 @@
// NO direct HTTP calls - everything routes through Rust backend // NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings"; import { commands } from "./bindings";
import type { JRayActor } from "./bindings";
import type { QualityPreset } from "./quality-presets"; import type { QualityPreset } from "./quality-presets";
import type { import type {
Library, Library,
@ -11,6 +12,7 @@ import type {
GetItemsOptions, GetItemsOptions,
SearchOptions, SearchOptions,
PlaybackInfo, PlaybackInfo,
LiveStreamInfo,
ImageType, ImageType,
ImageOptions, ImageOptions,
Genre, Genre,
@ -89,6 +91,15 @@ export class RepositoryClient {
return commands.repositoryGetItem(this.ensureHandle(), itemId); 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[]> { async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null); return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
} }
@ -109,12 +120,17 @@ export class RepositoryClient {
return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null); 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[]> { async getGenres(parentId?: string): Promise<Genre[]> {
return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null); return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
} }
async search(query: string, options?: SearchOptions): Promise<SearchResult> { async search(query: string, options?: SearchOptions, requestId = 0): Promise<SearchResult> {
return commands.repositorySearch(this.ensureHandle(), query, options ?? null); return commands.repositorySearch(this.ensureHandle(), query, options ?? null, requestId);
} }
// ===== Playback Methods (all via Rust) ===== // ===== 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) ===== // ===== URL Construction Methods (sync, no server call) =====
/** /**

View File

@ -5,12 +5,14 @@
// and adds frontend-only unions/helpers that have no backend equivalent. // and adds frontend-only unions/helpers that have no backend equivalent.
export type { export type {
ArtistItem,
AuthResult, AuthResult,
Genre, Genre,
GetItemsOptions, GetItemsOptions,
ImageOptions, ImageOptions,
ImageType, ImageType,
Library, Library,
LiveStreamInfo,
MediaItem, MediaItem,
MediaSource, MediaSource,
MediaStream, MediaStream,
@ -50,6 +52,7 @@ export type ItemType =
| "CollectionFolder" | "CollectionFolder"
| "Channel" | "Channel"
| "ChannelFolderItem" | "ChannelFolderItem"
| "TvChannel"
| "Person"; | "Person";
export type LibraryType = export type LibraryType =
@ -62,6 +65,7 @@ export type LibraryType =
| "boxsets" | "boxsets"
| "playlists" | "playlists"
| "channels" | "channels"
| "livetv"
| "unknown"; | "unknown";
export type PersonType = export type PersonType =

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