Compare commits

...

22 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
138 changed files with 6412 additions and 827 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

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

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)"

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

@ -161,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
@ -262,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() {
@ -760,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)
} }
@ -867,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: 23 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 971 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 915 KiB

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

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

@ -1087,7 +1087,7 @@ pub async fn enqueue_video_downloads(
/// (FIFO within a priority), registers each, flips it to `downloading`, and /// (FIFO within a priority), registers each, flips it to `downloading`, and
/// spawns a worker. Each spawned worker calls this again on completion/failure, /// spawns a worker. Each spawned worker calls this again on completion/failure,
/// so the queue drains itself without any frontend involvement. /// so the queue drains itself without any frontend involvement.
async fn pump_download_queue( pub(crate) async fn pump_download_queue(
app: tauri::AppHandle, app: tauri::AppHandle,
db_service: Arc<crate::storage::db_service::RusqliteService>, db_service: Arc<crate::storage::db_service::RusqliteService>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>, active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
@ -1268,13 +1268,50 @@ fn spawn_download_worker(
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len()); debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
} }
// 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 { match result {
Ok(res) => { Ok(res) => {
info!("Download completed successfully: {} bytes", res.bytes_downloaded); 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,
file_path: target_path.to_string_lossy().to_string(), file_path,
}; };
match app.emit("download-event", completed_event) { match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"), Ok(_) => debug!(" Completed event emitted successfully"),
@ -1283,6 +1320,18 @@ fn spawn_download_worker(
} }
Err(e) => { Err(e) => {
error!("Download failed: {:?}", e); error!("Download failed: {:?}", e);
let update = Query::with_params(
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
vec![
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);
}
let failed_event = DownloadEvent::Failed { let failed_event = DownloadEvent::Failed {
download_id, download_id,
item_id, item_id,
@ -1296,17 +1345,6 @@ fn spawn_download_worker(
} }
// A slot just freed — start the next pending download (if any). // A slot just freed — start the next pending download (if any).
let db_service = {
let db = app.state::<DatabaseWrapper>();
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
return;
}
};
Arc::new(database.service())
};
pump_download_queue(app.clone(), db_service, active_downloads).await; pump_download_queue(app.clone(), db_service, active_downloads).await;
}); });
} }

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

@ -378,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.
@ -679,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");
@ -697,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();
@ -732,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();
@ -748,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();
@ -1353,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();
@ -1374,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;
}
} }
}; };
@ -1420,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
@ -1669,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
@ -1712,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;
@ -1727,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()),
@ -1746,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()),
@ -1761,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()),
], ],
); );
@ -1780,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 {
@ -1826,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");
@ -1839,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(())
} }
@ -1858,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

@ -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

@ -52,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,
@ -115,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)
} }
@ -179,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]

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,6 +23,7 @@ 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,
@ -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,
@ -96,7 +99,7 @@ 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_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info, repository_get_genres, repository_search, repository_get_playback_info,
@ -148,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),
} }
} }
} }
@ -404,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,
@ -508,6 +586,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
start_download, start_download,
enqueue_download, enqueue_download,
enqueue_video_downloads, 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,
@ -562,6 +643,7 @@ 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,
@ -604,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
@ -611,6 +768,13 @@ 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
@ -732,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());
} }
@ -775,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(),
@ -829,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());
@ -858,9 +1035,13 @@ 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");

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.
@ -1117,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

@ -78,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
@ -119,6 +124,23 @@ 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>,
},
} }
/// Trait for emitting player events to the frontend. /// Trait for emitting player events to the frontend.

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;
@ -566,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();
@ -670,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);
@ -726,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
@ -842,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 {
@ -899,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 = {
@ -932,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();
@ -983,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);
} }
}; };
@ -1005,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)
@ -1071,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();
@ -1729,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

@ -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(
@ -73,6 +79,26 @@ impl HybridRepository {
.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). /// Search only the live Jellyfin server (full library).
pub async fn search_server_only( pub async fn search_server_only(
&self, &self,
@ -190,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> {
@ -371,20 +427,60 @@ impl MediaRepository for HybridRepository {
} }
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> { async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
let offline = Arc::clone(&self.offline); // Cache-first (100ms). On a cache hit, refresh the cached genre catalog
let online = Arc::clone(&self.online); // 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 parent_id_str = parent_id.map(|s| s.to_string());
let parent_id_clone = parent_id_str.clone();
let cache_future = self.cache_with_timeout(async move { let cache_offline = Arc::clone(&self.offline);
offline.get_genres(parent_id_str.as_deref()).await 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;
}
}
let server_future = async move { // Cache miss — fetch from server and persist for offline use.
online.get_genres(parent_id_clone.as_deref()).await match self.online.get_genres(parent_id_str.as_deref()).await {
}; Ok(server_genres) => {
if !server_genres.is_empty() {
self.parallel_race(cache_future, server_future).await 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> {
@ -1023,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()),

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;

View File

@ -39,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,
@ -101,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>,
@ -145,8 +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 column in every SELECT that maps through this fn. // Appended as the final columns in every SELECT that maps through this fn.
is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0, is_folder: row.get::<_, Option<i64>>(23)?.unwrap_or(0) != 0,
premiere_date: row.get(24)?,
}) })
} }
@ -238,7 +241,7 @@ impl OfflineRepository {
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
@ -248,10 +251,10 @@ impl OfflineRepository {
?9, ?10, ?11, ?9, ?10, ?11,
?12, ?13, ?14, ?15, ?12, ?13, ?14, ?15,
?16, ?17, ?18, ?19, ?16, ?17, ?18, ?19,
?20, ?21, ?20, ?21, ?22,
?22, ?23, ?23, ?24,
?24, ?25, ?25, ?26,
?26 ?27
)", )",
vec![ vec![
QueryParam::String(item.id.clone()), QueryParam::String(item.id.clone()),
@ -318,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,
@ -352,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(
@ -406,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())],
); );
@ -446,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() {
@ -480,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')
@ -496,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.is_folder 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 = ?
], ],
); );
@ -552,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')
@ -561,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.is_folder 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 = ?",
@ -597,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')
@ -606,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.is_folder 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 = ?
@ -644,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.is_folder 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
@ -669,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.is_folder 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
@ -730,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'
@ -757,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.is_folder 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
@ -805,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.is_folder 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
@ -835,59 +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> {
// Derive genres from cached albums, tallying how many albums carry each // Read the cached server genre catalog (populated by the hybrid repo via
// so the frontend can rank by popularity. We scope to MusicAlbum (genres // save_genres_to_cache). This is the FULL genre list for the library, not
// power the music landing) and read every matching row — NOT DISTINCT — // just the genres derivable from locally-cached albums — so offline keeps
// so the per-genre counts are real. Genres are stored as a JSON array // the same variety the server has. library_id NULL is stored as ''.
// string per item. let library_id = parent_id.unwrap_or("").to_string();
let (sql, params) = if let Some(pid) = parent_id {
( let query = Query::with_params(
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \ "SELECT id, name, album_count FROM genres WHERE server_id = ? AND library_id = ?",
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![ vec![
QueryParam::String(self.server_id.clone()), QueryParam::String(self.server_id.clone()),
QueryParam::String(pid.to_string()), QueryParam::String(library_id),
], ],
) );
} else {
(
"SELECT genres FROM items WHERE server_id = ? \
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
vec![QueryParam::String(self.server_id.clone())],
)
};
let query = Query::with_params(sql, params); let genres: Vec<Genre> = self
let genres_rows: Vec<String> = self
.db_service .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 })?;
// genre name -> album count
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
for genres_json in genres_rows {
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
// De-dupe within one album so a genre listed twice counts once.
let mut seen = std::collections::HashSet::new();
for genre in genres_vec {
if seen.insert(genre.clone()) {
*counts.entry(genre).or_insert(0) += 1;
}
}
}
}
let genres = counts
.into_iter()
.map(|(name, count)| Genre {
id: name.clone(),
name,
album_count: Some(count),
})
.collect();
Ok(genres) Ok(genres)
} }
@ -929,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')
@ -939,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.is_folder 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
@ -1099,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,
@ -1142,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')
@ -1152,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.is_folder 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
@ -1263,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.is_folder \ 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 = ? \
@ -1302,6 +1390,7 @@ impl MediaRepository for OfflineRepository {
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, 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))
}) })
@ -1473,6 +1562,7 @@ mod tests {
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,
@ -1524,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
@ -1549,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,
@ -1703,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
@ -369,6 +418,7 @@ struct JellyfinItem {
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>,
@ -459,6 +509,7 @@ impl JellyfinItem {
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,
@ -586,7 +637,7 @@ impl MediaRepository for OnlineRepository {
// Request image fields for list views (People only needed in get_item // Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres, // detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts. // which lets the offline store derive genre lists + per-genre counts.
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
let response: ItemsResponse = self.get_json(&endpoint).await?; let response: ItemsResponse = self.get_json(&endpoint).await?;
@ -601,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());
@ -738,6 +789,7 @@ impl MediaRepository for OnlineRepository {
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,
@ -905,7 +957,7 @@ impl MediaRepository for OnlineRepository {
// Request image fields for list views (plus Genres so cached items // Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts). // carry genres for offline genre lists/counts).
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres"); 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 {
@ -1372,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
@ -1385,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
} }
@ -1718,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]
@ -1846,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
@ -1958,4 +2126,34 @@ mod tests {
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars"); assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry"); 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

@ -119,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")]
@ -506,6 +510,7 @@ mod tests {
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,
@ -646,6 +651,7 @@ mod tests {
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,
@ -706,6 +712,7 @@ mod tests {
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

@ -23,6 +23,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("016_autoplay_max_episodes", MIGRATION_016), ("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017), ("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018), ("018_items_is_folder", MIGRATION_018),
("019_genres_cache", MIGRATION_019),
]; ];
/// Initial schema migration /// Initial schema migration
@ -693,3 +694,23 @@ 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. -- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL; 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
@ -787,6 +806,38 @@ async enqueueDownload(downloadId: number, streamUrl: string, targetDir: string)
async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> { async enqueueVideoDownloads(handle: string, downloadIds: number[], targetDir: string) : Promise<null> {
return await TAURI_INVOKE("enqueue_video_downloads", { handle, downloadIds, targetDir }); 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
*/ */
@ -1068,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
*/ */
@ -1478,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
*/ */
@ -1553,6 +1626,14 @@ 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)
*/ */
@ -1580,7 +1661,12 @@ export type MediaItem = { id: string; name: string; type: string;
* Whether this item is a folder/container (vs a playable leaf). Used to * 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. * 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; 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 } 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
*/ */
@ -1922,6 +2008,13 @@ export type PlayerStatusEvent =
* Sleep timer state changed * Sleep timer state changed
*/ */
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } | { 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 * Show next episode popup with countdown
*/ */
@ -1941,7 +2034,23 @@ export type PlayerStatusEvent =
/** /**
* Remote sessions updated (for cast/remote control UI) * Remote sessions updated (for cast/remote control UI)
*/ */
{ type: "sessions_updated"; sessions: SessionInfo[] } { 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
* *
@ -1963,7 +2072,12 @@ export type PlaylistEntry =
* Whether this item is a folder/container (vs a playable leaf). Used to * 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. * 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; 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 }) & { 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)
*/ */
@ -1998,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

@ -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,
@ -90,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);
} }

View File

@ -2,6 +2,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { library } from '$lib/stores/library';
// When a className is supplied the parent positions this bar (e.g. inside a
// measured in-flow stack); otherwise it self-positions as a fixed bottom bar.
let { className = "fixed bottom-0 left-0 right-0 z-40" }: { className?: string } = $props();
// Determine if a route is active // Determine if a route is active
function isActive(path: string): boolean { function isActive(path: string): boolean {
@ -15,7 +20,7 @@
</script> </script>
<!-- Navigation bar visible on all platforms --> <!-- Navigation bar visible on all platforms -->
<nav class="fixed bottom-0 left-0 right-0 bg-[var(--color-surface)] border-t border-gray-800 z-40"> <nav class="{className} bg-[var(--color-surface)] border-t border-gray-800">
<div class="flex items-center justify-around px-4 py-2"> <div class="flex items-center justify-around px-4 py-2">
<!-- Home Button --> <!-- Home Button -->
<button <button
@ -43,7 +48,7 @@
<!-- Library Button --> <!-- Library Button -->
<button <button
onclick={() => goto('/library')} onclick={() => { library.setCurrentLibrary(null); goto('/library'); }}
class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}" class="flex flex-col items-center gap-1 py-2 px-4 transition-colors {isActive('/library') ? 'text-[var(--color-jellyfin)]' : 'text-gray-400 hover:text-white'}"
aria-label="Library" aria-label="Library"
> >

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@ -152,7 +153,7 @@
class="flex-1 min-w-0 text-left" class="flex-1 min-w-0 text-left"
> >
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.album_name} {truncateMiddle(album.album_name, 40)}
</p> </p>
<p class="text-xs text-gray-400 truncate"> <p class="text-xs text-gray-400 truncate">
{album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"} {album.artist_name || "Unknown Artist"}{album.track_count} {album.track_count === 1 ? "track" : "tracks"}

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte"; import LibraryGrid from "./LibraryGrid.svelte";
@ -171,7 +172,7 @@
{/if} {/if}
</div> </div>
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{album.name} {truncateMiddle(album.name, 40)}
</p> </p>
{#if album.productionYear} {#if album.productionYear}
<p class="text-xs text-gray-400">{album.productionYear}</p> <p class="text-xs text-gray-400">{album.productionYear}</p>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@ -159,7 +160,7 @@
<!-- Episode title --> <!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg"> <h1 class="text-4xl font-bold text-white drop-shadow-lg">
{episode.name} {truncateMiddle(episode.name, 64)}
</h1> </h1>
<!-- Metadata --> <!-- Metadata -->

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
@ -134,7 +135,7 @@
{episodeNumber}. {episodeNumber}.
</span> </span>
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"> <h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{episode.name} {truncateMiddle(episode.name, 56)}
</h3> </h3>
<!-- Played indicator --> <!-- Played indicator -->
{#if episode.userData?.isPlayed} {#if episode.userData?.isPlayed}

View File

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation"; import { navigateBack } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library"; import { currentLibrary } from "$lib/stores/library";
@ -52,6 +54,16 @@
onMount(async () => { onMount(async () => {
await loadGenres(); await loadGenres();
// Auto-select a genre when linked with ?genre=<name> (e.g. from a genre tag)
const requestedGenre = $page.url.searchParams.get("genre");
if (requestedGenre) {
const match = genres.find(
(g) => g.name.toLowerCase() === requestedGenre.toLowerCase(),
);
if (match) {
await loadGenreItems(match);
}
}
markLoaded(); markLoaded();
}); });
@ -229,7 +241,7 @@
/> />
</div> </div>
<p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 40)}
</p> </p>
{#if item.productionYear} {#if item.productionYear}
<p class="text-sm text-gray-400"> <p class="text-sm text-gray-400">

View File

@ -5,14 +5,34 @@
genres: string[]; genres: string[];
maxShow?: number; // Default: unlimited maxShow?: number; // Default: unlimited
clickable?: boolean; // Default: true clickable?: boolean; // Default: true
itemType?: string; // Determines which genre browse page to open
} }
let { let {
genres, genres,
maxShow, maxShow,
clickable = true clickable = true,
itemType
}: Props = $props(); }: Props = $props();
// Map the item type to its genre-browse route
function genreBasePath(type: string | undefined): string {
switch (type) {
case "MusicAlbum":
case "MusicArtist":
case "Audio":
return "/library/music/genres";
case "Series":
case "Season":
case "Episode":
return "/library/shows/genres";
case "Movie":
return "/library/movies/genres";
default:
return "/library/movies/genres";
}
}
const displayGenres = $derived( const displayGenres = $derived(
maxShow ? genres.slice(0, maxShow) : genres maxShow ? genres.slice(0, maxShow) : genres
); );
@ -23,9 +43,7 @@
function handleGenreClick(genre: string) { function handleGenreClick(genre: string) {
if (clickable) { if (clickable) {
// Navigate to genre browse page goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
// For now, we'll use a simple navigation - could be enhanced with a proper genre browse page
goto(`/search?genre=${encodeURIComponent(genre)}`);
} }
} }
</script> </script>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { formatDuration } from "$lib/utils/duration"; import { formatDuration } from "$lib/utils/duration";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
@ -106,7 +107,7 @@
<!-- Title & Subtitle --> <!-- Title & Subtitle -->
<div class="flex-1 min-w-0 text-left"> <div class="flex-1 min-w-0 text-left">
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 56)}
</p> </p>
{#if subtitle} {#if subtitle}
<p class="text-xs text-gray-400 truncate">{subtitle}</p> <p class="text-xs text-gray-400 truncate">{subtitle}</p>

View File

@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/svelte";
// Controllable stores for the offline "server only" branch. Declared via
// vi.hoisted so they exist when the hoisted vi.mock factories run. A tiny
// writable shim avoids importing svelte inside the hoisted block.
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
isConnectedStore: shim(true),
showServerCatalogStore: shim(false),
downloadsStore: shim({ downloads: {} as Record<string, any> }),
downloadItem: vi.fn(async () => 1),
getUserId: vi.fn(() => "user-1"),
};
});
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
}));
vi.mock("$lib/services/offlineCatalog", () => ({
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
}));
vi.mock("$lib/stores/downloads", () => ({
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: h.getUserId },
}));
// CachedImage does async repo/image work irrelevant to these tests.
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
default: (await import("./__mocks__/StubImage.svelte")).default,
}));
import MediaCard from "./MediaCard.svelte";
const track = {
id: "track-1",
name: "Some Song",
type: "Audio" as const,
serverId: "server-1",
artists: ["Artist A"],
albumName: "Album X",
};
describe("MediaCard server-only (offline browse & queue)", () => {
beforeEach(() => {
vi.clearAllMocks();
isConnectedStore.set(true);
showServerCatalogStore.set(false);
downloadsStore.set({ downloads: {} });
});
it("shows no queue button while online", () => {
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("shows no queue button when offline but the reveal toggle is off", () => {
isConnectedStore.set(false);
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("reveals a queue button when offline and the reveal toggle is on", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
render(MediaCard, { props: { item: track } });
expect(screen.getByLabelText(/Queue download for Some Song/i)).toBeTruthy();
});
it("queues the item for download with its metadata on click", async () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
render(MediaCard, { props: { item: track } });
await fireEvent.click(screen.getByLabelText(/Queue download for Some Song/i));
expect(downloadItem).toHaveBeenCalledTimes(1);
const args = downloadItem.mock.calls[0] as unknown as any[];
expect(args[0]).toBe("track-1"); // itemId
expect(args[1]).toBe("user-1"); // userId
expect(args[5]).toBe("Some Song"); // itemName
expect(args[6]).toBe("Artist A"); // artistName
expect(args[7]).toBe("Album X"); // albumName
});
it("shows a Queued badge (not the button) for a pending download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
downloadsStore.set({
downloads: { "track-1": { itemId: "track-1", status: "pending", progress: 0 } },
});
render(MediaCard, { props: { item: track } });
expect(screen.getByText(/Queued/i)).toBeTruthy();
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
it("does not grey out a completed download", () => {
isConnectedStore.set(false);
showServerCatalogStore.set(true);
downloadsStore.set({
downloads: { "track-1": { itemId: "track-1", status: "completed", progress: 1 } },
});
render(MediaCard, { props: { item: track } });
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
});
});

View File

@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { isConnected } from "$lib/stores/connectivity";
import { showServerCatalog } from "$lib/services/offlineCatalog";
import { auth } from "$lib/stores/auth";
import CachedImage from "$lib/components/common/CachedImage.svelte"; import CachedImage from "$lib/components/common/CachedImage.svelte";
interface Props { interface Props {
@ -24,6 +28,55 @@
); );
const downloadProgress = $derived(downloadInfo?.progress || 0); const downloadProgress = $derived(downloadInfo?.progress || 0);
// A media item (not a library) is "server only" when it exists in the cache
// but isn't downloaded/downloading — and we're offline with the reveal toggle
// on. Such cards render greyed out with a "queue for download" button and are
// inert to tap (nothing to play offline).
const isMediaItem = $derived("type" in item);
const isQueued = $derived(downloadInfo?.status === "pending");
// Actively transferring (as opposed to merely queued/pending for reconnect).
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
// "Server only" = offline, reveal on, and not already downloaded or actively
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
// it can show the Queued badge in place of the queue button.
const isServerOnly = $derived(
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading
);
let queueError = $state<string | null>(null);
// Queue this item for download on next reconnect. Offline, this just persists
// a `pending` downloads row (no stream_url); the reconnect handler resolves
// the URL and the pump starts it. See offlineCatalog service.
async function queueForDownload(e: Event) {
e.stopPropagation();
if (!isMediaItem) return;
const media = item as MediaItem;
const userId = auth.getUserId();
if (!userId) {
queueError = "Not signed in";
return;
}
try {
queueError = null;
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
const filePath = `downloads/${media.id}`;
await downloads.downloadItem(
media.id,
userId,
filePath,
undefined,
undefined,
media.name,
media.artists?.join(", ") ?? undefined,
media.albumName ?? undefined
);
} catch (err) {
console.error("[MediaCard] Failed to queue download:", err);
queueError = "Failed to queue";
}
}
const sizeClasses = { const sizeClasses = {
small: "w-24", small: "w-24",
medium: "w-36", medium: "w-36",
@ -75,10 +128,12 @@
}); });
</script> </script>
<button <svelte:element
type="button" this={isServerOnly ? "div" : "button"}
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 hover:scale-105" type={isServerOnly ? undefined : "button"}
{onclick} role={isServerOnly ? "group" : undefined}
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
onclick={isServerOnly ? undefined : onclick}
> >
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200"> <div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
<CachedImage <CachedImage
@ -87,11 +142,12 @@
tag={imageTag} tag={imageTag}
maxWidth={maxWidth} maxWidth={maxWidth}
alt={item.name} alt={item.name}
class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110" class="w-full h-full object-cover transition-transform duration-300 group-hover/card:scale-110 {isServerOnly ? 'opacity-40 grayscale' : ''}"
/> />
<!-- Hover overlay with smooth gradient --> <!-- Hover overlay with smooth gradient (play affordance; hidden for
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 group-hover/card:opacity-100 transition-opacity duration-300 flex items-center justify-center"> server-only cards, which can't be played offline) -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 opacity-0 {isServerOnly ? '' : 'group-hover/card:opacity-100'} transition-opacity duration-300 flex items-center justify-center">
<div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300"> <div class="transform scale-90 group-hover/card:scale-100 opacity-0 group-hover/card:opacity-100 transition-all duration-300">
<div class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl"> <div class="w-14 h-14 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-2xl">
<svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-7 h-7 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
@ -162,14 +218,48 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Server-only: queue-for-download control (kept at full opacity over the
greyed artwork). Queued items show a "queued" badge instead. -->
{#if isServerOnly}
<div class="absolute inset-0 flex items-center justify-center">
{#if isQueued}
<div class="flex flex-col items-center gap-1 text-white" title="Queued — will download on reconnect">
<div class="w-11 h-11 rounded-full bg-black/60 flex items-center justify-center shadow-lg">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-[10px] font-medium bg-black/60 px-1.5 py-0.5 rounded-full">Queued</span>
</div>
{:else}
<button
type="button"
onclick={queueForDownload}
class="w-11 h-11 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center shadow-lg transition-colors"
title="Queue download for next connection"
aria-label="Queue download for {item.name}"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
</button>
{/if}
</div>
{#if queueError}
<div class="absolute bottom-1 left-1 right-1 text-center text-[10px] text-red-200 bg-black/70 rounded px-1 py-0.5">
{queueError}
</div>
{/if}
{/if}
</div> </div>
<div class="mt-2 space-y-0.5"> <div class="mt-2 space-y-0.5 {isServerOnly ? 'opacity-60' : ''}">
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors"> <p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
{item.name} {truncateMiddle(item.name, 40)}
</p> </p>
{#if subtitle()} {#if subtitle()}
<p class="text-xs text-gray-400 truncate">{subtitle()}</p> <p class="text-xs text-gray-400 truncate">{subtitle()}</p>
{/if} {/if}
</div> </div>
</button> </svelte:element>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings"; import { playerController } from "$lib/player";
import type { MediaItem, PlaylistEntry } from "$lib/api/types"; import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast"; import { toast } from "$lib/stores/toast";
@ -48,10 +48,8 @@
async function handlePlayAll() { async function handlePlayAll() {
if (entries.length === 0) return; if (entries.length === 0) return;
try { try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await commands.playerPlayTracks(repositoryHandle, { await playerController.playTracks({
trackIds, trackIds,
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@ -70,10 +68,8 @@
async function handleShufflePlay() { async function handleShufflePlay() {
if (entries.length === 0) return; if (entries.length === 0) return;
try { try {
const repo = auth.getRepository();
const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await commands.playerPlayTracks(repositoryHandle, { await playerController.playTracks({
trackIds, trackIds,
startIndex: 0, startIndex: 0,
shuffle: true, shuffle: true,

View File

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { commands } from "$lib/api/bindings"; import { playerController } from "$lib/player";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { PlayTracksContext } from "$lib/api/bindings"; import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue"; import { queue } from "$lib/stores/queue";
import { auth } from "$lib/stores/auth";
import { currentMedia } from "$lib/stores/player"; import { currentMedia } from "$lib/stores/player";
import { toast } from "$lib/stores/toast"; import { toast } from "$lib/stores/toast";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@ -53,17 +53,10 @@
try { try {
isPlayingTrack = track.id; isPlayingTrack = track.id;
// Validate auth before proceeding
const repo = auth.getRepository();
if (!repo) {
throw new Error("Not authenticated");
}
// If this is an album, use the backend album command (more efficient) // If this is an album, use the backend album command (more efficient)
if (context && context.type === "album") { if (context && context.type === "album") {
const repositoryHandle = repo.getHandle();
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`); console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
await commands.playerPlayAlbumTrack(repositoryHandle, { await playerController.playAlbumTrack({
albumId: context.albumId, albumId: context.albumId,
albumName: context.albumName, albumName: context.albumName,
trackId: track.id, trackId: track.id,
@ -74,7 +67,6 @@
// Use new backend command for non-album contexts (playlists, custom queues, etc.) // Use new backend command for non-album contexts (playlists, custom queues, etc.)
// Backend handles all metadata fetching and queue building // Backend handles all metadata fetching and queue building
const repositoryHandle = repo.getHandle();
const trackIds = tracks.map((t) => t.id); const trackIds = tracks.map((t) => t.id);
// Determine context for queue // Determine context for queue
@ -89,7 +81,7 @@
playContext = { type: "custom", label: null }; playContext = { type: "custom", label: null };
} }
await commands.playerPlayTracks(repositoryHandle, { await playerController.playTracks({
trackIds, trackIds,
startIndex: index, startIndex: index,
shuffle: false, shuffle: false,
@ -242,7 +234,7 @@
<span <span
class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}" class="text-white font-medium truncate group-hover:text-[var(--color-jellyfin)] transition-colors {currentlyPlayingId === track.id ? 'text-[var(--color-jellyfin)]' : ''}"
> >
{track.name} {truncateMiddle(track.name, 48)}
</span> </span>
</div> </div>
@ -356,7 +348,7 @@
{#if currentlyPlayingId === track.id} {#if currentlyPlayingId === track.id}
<span class="inline-block mr-1"></span> <span class="inline-block mr-1"></span>
{/if} {/if}
{track.name} {truncateMiddle(track.name, 48)}
</p> </p>
<p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1"> <p class="text-sm text-gray-400 truncate flex flex-wrap items-center gap-1">
{#if showArtist && showAlbum} {#if showArtist && showAlbum}

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