Compare commits

..
14 Commits
Author SHA1 Message Date
dtourolle bebe13eb62 ci: drop redundant setup-bun step that stalls Gitea runner
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Failing after 5m23s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m41s
Build & Release / Build Linux (push) Successful in 17m20s
Build & Release / Build Android (push) Successful in 22m29s
Build & Release / Create Release (push) Successful in 5s
bun is already baked into the jellytau-builder image (Dockerfile.builder),
so oven-sh/setup-bun@v1 was redundant. Fetching that GitHub-hosted action
from the self-hosted Gitea runner hangs the job before any steps run.
Removed from traceability-check, traceability, and publish-docs workflows;
build-and-test and build-release never used it and never stalled.
2026-07-23 09:45:07 +02:00
dtourolle a8adbe25cc Merge pull request 'android-picture-in-picture' (#12) from android-picture-in-picture into master
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 3h14m1s
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Run Tests (push) Successful in 10m40s
Build & Release / Build Linux (push) Successful in 17m14s
Build & Release / Build Android (push) Successful in 22m26s
Build & Release / Create Release (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Reviewed-on: #12
2026-07-22 20:29:04 +00:00
dtourolle acf1bb200d fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled
2026-07-22 22:28:07 +02:00
dtourolle 3fbf6afdbc Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
2026-07-22 21:52:07 +02:00
dtourolle 4e6ab017d4 docs: add mdBook docs-site, publish workflow, and release-notes tooling
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
2026-07-22 21:51:56 +02:00
dtourolleandClaude Opus 4.8 027054a200 Bump version to 0.0.16
Needed to deploy over the CI-installed build on device: CI derives
versionCode as 1000 + major*10000 + minor*100 + patch, so the field is
already at 1000, while a local `tauri android build` writes the raw
patch number (15) and is rejected as a downgrade.

Cargo.toml is versioned independently (0.1.0) and is left alone.

Note: local builds still emit the raw code (16) - only CI applies the
1000+ formula, so deploying to a device with a CI build installed needs
gen/android/app/tauri.properties patched after Tauri regenerates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:57:23 +02:00
dtourolleandClaude Opus 4.8 1fa5aa46f9 Android picture-in-picture, and fix three dead Android config files
Add PiP for native (ExoPlayer) video on Android. Video renders into a
SurfaceView behind the WebView, so PiP is driven by the Activity shrinking
into a floating window rather than the HTML5 PiP API (which WebKitGTK does
not implement, hence Android-only).

- PictureInPictureManager.kt: enter PiP with the video's aspect ratio
  (clamped to the 1:2.39-2.39:1 range Android accepts, outside which it
  throws), plus a play/pause RemoteAction. Hides the WebView while in PiP -
  it is opaque and sits above the surface, so it would otherwise occlude the
  video entirely - and re-fits the surface on exit.
- MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged,
  and an AndroidPictureInPicture JS interface following the existing
  AndroidAudioFocus pattern.
- pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when
  the native bridge reports support.
- proguard: keep rules for @JavascriptInterface methods, which are only
  referenced from JS and would be stripped in minified release builds.

Casting needs no special handling: canEnterPip() checks natively that a
local video surface is attached and playing, which a remote session lacks.

While wiring the manifest, found that three tracked files under
src-tauri/android/ were never reaching any build. Gradle reads only
gen/android/app/src/main/, and sync-android-sources.sh did not copy them:

- src/main/AndroidManifest.xml was a partial <application> fragment written
  as if Tauri merged it. It does not - there is no manifest-merger hook
  here, so its hardwareAccelerated flag never reached an APK. Promoted to
  the complete authoritative manifest (folding in that flag) and synced.
- src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows)
  was never copied; the sync only globbed mipmap-*. Now synced.
- build.gradle.kts was a leftover com.android.library module config with
  stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at
  1.5.0. Deleted.

Verified: merged manifest now carries hardwareAccelerated,
supportsPictureInPicture, resizeableActivity and the density configChange;
themes.xml compiles into merged resources; Kotlin builds warning-free;
svelte-check clean; 537 frontend tests pass.

Not verified: PiP behaviour on a device, and the release keep rules against
a minified build. assembleUniversalDebug cannot complete in this
environment - the Rust step wants a dev-server addr file that only exists
under `tauri android dev`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:59:39 +02:00
dtourolleandClaude Opus 4.8 7b8a8f66e5 CI: make versionCode step POSIX sh compatible
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m24s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m31s
Build & Release / Build Linux (push) Successful in 17m40s
Build & Release / Build Android (push) Successful in 22m33s
Build & Release / Create Release (push) Successful in 14s
The runner executes workflow steps with /bin/sh (dash), which has no
here-strings: `IFS='.' read -r MAJ MIN PAT <<< "$VERSION"` failed with
"Syntax error: redirection unexpected" and aborted the Android release build.

Parse the semver with `cut` instead, drop the GNU-only `\s` from the sed
expression in favour of [[:space:]], and default any missing component to 0 so a
malformed version can never emit versionCode 0. Verified under sh:
0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000 (monotonic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:23:04 +02:00
dtourolleandClaude Opus 4.8 2e479d05b3 Navigation up/back split, faster startup, and CI versionCode fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m57s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Build & Release / Build Linux (push) Successful in 17m52s
Build & Release / Build Android (push) Failing after 58s
Build & Release / Create Release (push) Has been skipped
Navigation:
- Split conflated "back" into navigateUp (deterministic route parent) and a
  history-safe navigateBack that tracks in-app depth via afterNavigate instead
  of history.length. Fixes the resume-from-background trap where a stale WebView
  stack left the header arrow stuck on the current page.
- /library self-corrects for music/tv/movies (which have dedicated landing
  pages): a leftover currentLibrary no longer forces the inline content-list
  view, so "up"/back shows the libraries overview. Live TV / channels / other
  types still render inline.

Startup (unblock first paint):
- auth.initialize() no longer awaits security-status, player-config, or session
  verification before flipping isInitialized. These run fire-and-forget after the
  session is restored, so the library overview paints without waiting on several
  serial IPC round-trips.

Versioning / CI:
- tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to
  0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags).
- Release workflow now pins a monotonic Android versionCode
  (1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade
  below prior installs and always increase in semver order.

Tests: navigation (4), auth (29), playbackMode (23) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:12:36 +02:00
dtourolle 1992a8187d layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s
2026-07-16 22:53:03 +02:00
dtourolle 532ffa661a Fix tests
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m29s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m4s
Build & Release / Run Tests (push) Successful in 4m52s
Build & Release / Build Linux (push) Successful in 17m55s
Build & Release / Build Android (push) Successful in 22m13s
Build & Release / Create Release (push) Successful in 13s
2026-07-11 22:09:33 +02:00
dtourolle 2a1f1689b4 Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-07-11 19:55:55 +02:00
dtourolle a2cd9978f0 build uses android signing key
🏗️ 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) Successful in 21m44s
Build & Release / Create Release (push) Successful in 15s
2026-07-07 18:05:17 +02:00
dtourolle 36be192d44 offline mode fixes
🏗️ 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
118 changed files with 9990 additions and 3559 deletions
+32 -3
View File
@@ -161,10 +161,10 @@ jobs:
- name: Set app version from tag
run: |
REF="${GITHUB_REF#refs/tags/v}"
VERSION="${REF#refs/heads/}"
# On non-tag runs keep whatever is in tauri.conf.json
# On a tag build, the tag is the single source of truth for the
# version name. On non-tag runs keep whatever is in tauri.conf.json.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
@@ -173,6 +173,35 @@ jobs:
- name: Initialize Android project
run: bun run tauri android init
- name: Pin a monotonic Android versionCode
run: |
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
# number is (a) tiny and (b) NOT monotonic across our history: earlier
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
# plain 15 would be a *downgrade* and Android would refuse the update.
#
# Derive an explicit code that is both monotonic in semver order and
# always above the 1000 floor already in the field:
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo "version=$VERSION -> versionCode=$CODE"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
cat "$PROPS"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
+122
View File
@@ -0,0 +1,122 @@
name: Publish Documentation
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
# API reference with cargo doc, and force-pushes the combined output to the
# orphan `gitea-pages` branch that the Gitea Pages server serves.
#
# The published matrix is regenerated during the build, so it is never stale.
on:
push:
branches:
- master
concurrency:
# Only one docs publish at a time; a newer push supersedes an in-flight run.
group: publish-docs
cancel-in-progress: true
jobs:
publish-docs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
- name: Install mdBook
run: |
set -e
MDBOOK_VERSION=v0.4.40
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
mdbook --version
- name: Regenerate traceability matrix (keep published copy current)
run: bun run traces:markdown
- name: Assemble mdBook sources
run: |
set -e
# mdBook's src is docs/. Drop in the SUMMARY and the generated
# intro + API redirect pages (build artifacts, not committed).
cp docs-site/SUMMARY.md docs/SUMMARY.md
cat > docs/README.md <<'EOF'
# JellyTau Documentation
Cross-platform Jellyfin client — business logic in a Rust backend,
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
_This site is published automatically from `master` by the `publish-docs` CI job._
EOF
cat > docs/api-redirect.md <<'EOF'
# Rust API Reference
The full backend API reference is generated by `cargo doc` (rustdoc).
👉 **[Open the Rust API Reference](api/index.html)**
EOF
- name: Build mdBook site
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
- name: Build Rust API docs (cargo doc)
working-directory: src-tauri
# --no-deps keeps it to our own crate (fast, focused); document private
# items so internal modules/commands appear.
run: |
cargo doc --no-deps --document-private-items
# The backend modules/commands live in the LIB crate (jellytau_lib);
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
> target/doc/index.html
- name: Assemble published output
run: |
set -e
mkdir -p "$GITHUB_WORKSPACE/site/api"
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
# Disable Jekyll processing on the pages branch.
touch "$GITHUB_WORKSPACE/site/.nojekyll"
ls -la "$GITHUB_WORKSPACE/site"
- name: Push to gitea-pages branch
env:
# PAT preferred; falls back to the auto-provided token (same pattern
# as build-release.yml).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
REPO="${GITHUB_REPOSITORY}"
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
cd "$GITHUB_WORKSPACE/site"
git init -q
git config user.name "gitea-actions"
git config user.email "actions@gitea.tourolle.paris"
git checkout -q -b gitea-pages
git add -A
git commit -q -m "docs: publish site from ${GITHUB_SHA::8}"
echo "🚀 Force-pushing to gitea-pages"
git push -f "$REMOTE" gitea-pages
+2 -3
View File
@@ -25,9 +25,8 @@ jobs:
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
+2 -5
View File
@@ -23,11 +23,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
+6
View File
@@ -58,3 +58,9 @@ android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
/docs/SUMMARY.md
/docs/README.md
/docs/api-redirect.md
/docs-site/book/
+245
View File
@@ -0,0 +1,245 @@
# JellyTau
A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
`<video>` for transcoded playback) and **Android** (ExoPlayer).
Package manager is **bun**.
## Build / Run / Test
All routine tasks go through `package.json` scripts and helper scripts in
`scripts/`:
```bash
bun install # install deps
bun run dev # vite dev server (frontend)
bun run tauri dev # run the desktop app
bun run check # svelte-check (types)
bun run test # vitest (frontend unit/integration)
bun run test:rust # cargo test (scripts/test-rust.sh)
bun run test:all # full suite (scripts/test-all.sh)
bun run test:e2e # webdriverio e2e
# Android — canonical entry points (see scripts/):
bun run android:build # debug APK
bun run android:build:release # release APK
bun run android:deploy # install to connected device
bun run android:dev # build + deploy
bun run android:logs # logcat
```
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
## Before Committing
- Frontend: `bun run check` and `bun run test` must pass.
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
comment (see below).
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
Never edit the generated `gen/` sources directly.
## Traceability (TRACES)
This project practices requirement-driven development: code that implements a
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
an extraction tool builds the traceability matrix. **When you add or change code
that implements a requirement, add/update its TRACES comment.** Internal helpers
and requirement-less code stay untraced.
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
```rust
/// TRACES: UR-005 | DR-001
pub enum PlayerState { }
```
```typescript
// TRACES: UR-005, UR-026 | DR-029
export function autoplayNextEpisode() { }
```
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
in [docs/requirements.md](docs/requirements.md); the generated matrix is
[docs/traceability.md](docs/traceability.md).
Tooling:
```bash
bun run traces # extract traces (default format)
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown # regenerate docs/traceability.md
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
```
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
GitHub. `traceability-check.yml` fails the build if coverage drops below
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
### Traces drive release notes
Prefer traceability over raw commit subjects when writing release notes for
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
release touched.
```bash
bun run release:notes # <latest tag>..HEAD
bun run release:notes v0.0.15..HEAD # explicit range
```
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
changed files → their `TRACES:` IDs → descriptions in
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
and **DR/IR** into *Improvements* (deduped, so many commits touching one
requirement collapse to one line). It also lists changed files that carry no
TRACES so nothing is silently dropped — those still need a manual line. Treat the
output as a reviewed draft, not a final changelog.
## Architecture
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
sessions, downloads, offline cache, playback control. Commands grouped by
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
`download/`, `offline.rs`, `sessions.rs`, …).
- **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`.
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
ExoPlayer with a foreground media service + `MediaSessionCompat`.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
**Read the architecture docs before making structural changes** — they are the
canonical, maintained source; this file only summarizes. See
[docs/architecture/README.md](docs/architecture/README.md) and:
| Doc | Contents |
|-----|----------|
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
and [docs/build-release.md](docs/build-release.md).
### Core principles (from the architecture docs)
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
Linux, session poller in remote mode) is the **authoritative source** of state
— position, pause, seeking, rate, track changes. The Svelte UI, OS
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
commands, so the controller stays the single source of truth in both native
and HTML5 modes.
- **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while
offline*, as a recovery detector.
- **Poison-tolerant locking.** Access shared `std::sync` state via the
`MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned
lock instead of cascading a panic across the player.
- **Graceful backend init.** If a native player backend fails to initialize, the
app falls back to a no-op backend and emits `backend-init-failed` rather than
crashing.
## Conventions
### Rust Backend
- Use `#[tauri::command]` for all IPC handlers.
- Prefer `async` commands for I/O-bound work.
- Return `Result<T, String>` from commands (the established convention here).
- Use `tauri::State<>` for shared state.
- Group related commands in domain modules under `commands/`.
- Use official Tauri plugins before writing custom native code.
### Frontend
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
- Define TS types matching the Rust structs; prefer the generated bindings.
- Handle IPC errors with try/catch.
- Use `@tauri-apps/api/path` for paths (never hardcode).
- Use `@tauri-apps/api/event` for backend→frontend events.
### 🔴 IPC parameter naming (Tauri v2)
The command **name** must match the Rust function name exactly
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
on the frontend:
```rust
#[tauri::command]
pub async fn cmd(repository_handle: String) { }
```
```typescript
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
```
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
### Events
- Backend events use **kebab-case** names (`download-event`, `search-event`).
- Emit from Rust via `emit(...)`; consume on the frontend via
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
### Security
- Declare minimum permissions in `src-tauri/capabilities/`.
- Keep the CSP restrictive in `tauri.conf.json`.
- Validate all inputs in Rust command handlers.
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
asking the user first.
## Gotchas (hard-won)
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
(it flips to HTML5 mode and breaks Android seek).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
Don't loop `startDownload` from the frontend.
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
file changes may be another session — check `git diff` before "repairing".
## Testing
```bash
# Rust
cd src-tauri && cargo test
cd src-tauri && cargo test test_name # single test
# Frontend
bun run test
bun run test:coverage
# Tauri IPC param-naming integration tests (guard the camelCase rule):
bun run test -- tauriIntegration.test.ts
```
+39
View File
@@ -0,0 +1,39 @@
# Summary
[Introduction](README.md)
# Requirements & Traceability
- [Requirements Specification](requirements.md)
- [Traceability Matrix](traceability.md)
- [Traceability CI](traceability-ci.md)
- [Traces Quick Reference](traces-quick-ref.md)
# Architecture
- [Overview](architecture/README.md)
- [Rust Backend](architecture/01-rust-backend.md)
- [Svelte Frontend](architecture/02-svelte-frontend.md)
- [Data Flow](architecture/03-data-flow.md)
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
- [Platform Backends](architecture/05-platform-backends.md)
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
- [Connectivity](architecture/07-connectivity.md)
- [Database Design](architecture/08-database-design.md)
- [Security](architecture/09-security.md)
# UX & Specs
- [UX Flows](ux-flows.md)
- [Video Background Audio](specs/video-background-audio.md)
# Build & Release
- [Build & Release](build-release.md)
- [Release Checklist](release-checklist.md)
- [Docker](build/docker.md)
- [Builder Image](build/build-builder-image.md)
---
[Rust API Reference (rustdoc)](api-redirect.md)
+25
View File
@@ -0,0 +1,25 @@
# mdBook config for the published JellyTau documentation site.
# The book's `src` is the repo `docs/` directory (see [build] below); this file
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
[book]
title = "JellyTau Documentation"
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
authors = ["Duncan Tourolle"]
language = "en"
# Sources live in the repo docs/ dir (one level up from this book root).
src = "../docs"
[output.html]
default-theme = "navy"
preferred-dark-theme = "navy"
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
[output.html.fold]
enable = true
level = 1
[output.html.search]
enable = true
+36 -1
View File
@@ -50,6 +50,14 @@ For a narrative overview of the system design, see
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
---
@@ -85,6 +93,10 @@ External system integrations and platform-specific implementations.
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
### 2.2 Jellyfin API Requirements
@@ -123,6 +135,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
### 2.3 Development Requirements
@@ -180,6 +193,16 @@ Internal architecture, components, and application logic.
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
---
@@ -198,7 +221,7 @@ Internal architecture, components, and application logic.
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
@@ -228,6 +251,14 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052 |
| UR-041 | IR-026 | DR-053 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
| UR-047 | IR-013 | DR-060 |
---
@@ -295,6 +326,9 @@ Internal architecture, components, and application logic.
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
### Integration Tests
@@ -312,6 +346,7 @@ Internal architecture, components, and application logic.
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
---
+233
View File
@@ -0,0 +1,233 @@
# Spec: Background audio for video playback (Android)
**Status:** Draft
**Scope:** Android only (v1). Linux noted as future work.
**Branch base:** `android-picture-in-picture`
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
## Summary
Add a per-player toggle that lets the **audio** of a video keep playing when the
app is backgrounded or the screen is locked, while **video decoding stops**.
When the app returns to the foreground, video decoding resumes from the current
audio position.
This is the audio-first counterpart to the existing Picture-in-Picture feature
(which keeps the *whole video* decoding in a floating window). The two are
mutually exclusive: enabling background audio suppresses auto-PiP.
## Motivation
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
concert films) want to lock the phone or switch apps and keep listening without
draining battery on video decode or needing a visible floating window.
## Background: how playback actually works here
Two facts drive the entire design (verified in code, not assumed):
1. **Video renders through the HTML5 `<video>` element in the WebView on both
platforms.** The native ExoPlayer *video* surface path is disabled — see the
INTERIM override in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
around the `playerPlayItem` response handling (`useHtml5Element` is forced
`true`, native backend is stopped). So "video decoding" == the WebView
`<video>` element, and the WebView is what Android suspends on background.
2. **An Android WebView `<video>` element does not keep playing audio when the
app is backgrounded / locked.** The system throttles the WebView and media
pauses. Keeping audio alive in the background requires a **native foreground
media service**, which already exists for music:
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
+
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
(ExoPlayer) + `MediaSessionCompat`.
**Therefore the design is a handoff**, not "keep the WebView alive": on
background, stop the WebView `<video>` and start audio-only playback of the same
item through the existing native ExoPlayer audio service; on foreground, hand
back to the WebView `<video>`.
This also aligns with the project's one-directional playback rule
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
player (WebView element **or** native audio service) drives position; the UI and
MediaSession consume it. The handoff is a change of *which* player is
authoritative, and must transfer position cleanly.
## User-facing behavior
### The toggle
- A toggle button in the video player controls (next to the existing PiP /
fullscreen buttons in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
Android with a native audio service available. Hidden on Linux in v1.
- State is a UI preference on the player. Consider persisting the last choice
per user (see Open Questions) — v1 may default OFF each session.
### When toggle is ON and the app goes to background / screen locks
1. Auto-PiP is suppressed (see "Interaction with PiP").
2. The WebView `<video>` is paused and its decode stopped (release the media
source so the decoder is freed, not merely `pause()`).
3. Native audio-only playback of the same item starts at the current position,
through `JellyTauPlaybackService` (foreground notification + lockscreen
controls via the existing `MediaSessionCompat`).
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
native player (existing music behavior — reused, not rebuilt).
### When toggle is ON and the app returns to foreground
1. Native audio playback stops; its final position is captured.
2. WebView `<video>` reloads/resumes at that position and continues as normal
audiovisual playback.
3. Playback state (playing/paused) is preserved across the handoff.
### When toggle is OFF (default)
Current behavior is unchanged: backgrounding video auto-enters PiP
(`onUserLeaveHint``PictureInPictureManager.enterPip`).
## Interaction with PiP
The toggle chooses one behavior or the other:
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
bridge already exists,
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
- Toggle **OFF**`setAutoEnterEnabled(true)`. Background → PiP (status quo).
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
it enabled, and re-assert the correct value whenever the toggle changes, so a
stale setting can't leak into the next player.
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
> false during normal playback. Confirm during implementation how auto-PiP is
> actually triggering today (it may rely on a different signal), because the
> background-audio handoff needs the same "is a local video active" signal to
> know it should fire. **This is a load-bearing unknown — resolve it first
> (Phase 0).**
## Technical design
### The audio-only stream
Jellyfin can transcode/stream a video item as audio-only. Add a repository
method (mirroring
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
**audio-only stream URL for a video item** at a given audio-stream index — so
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
carries over. Prefer direct-play of the audio stream where the container/codec
allows; transcode to a broadly-supported audio codec otherwise.
Position semantics must match between the WebView `<video>` timeline and the
audio stream (account for the transcoded-HLS `seekOffset` model already in the
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
### Backend command surface (Rust)
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
camelCase param rule and `Result<T, String>` convention):
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
— stop WebView authority, start native audio-only playback at position; makes
the native player authoritative. Emits state via the existing player-event
channel so MediaSession/UI stay consumers.
- `player_exit_background_audio() -> position_seconds` — stop native audio,
return final position for the WebView to resume from; restores WebView
authority.
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
than adding a parallel path.
### Android native
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
all already implemented for music).
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
one so the frontend can signal "prepare for background audio handoff" tied to
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
enter PiP; instead trigger the handoff command.
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
player event callbacks; bind locked `AutoplayDecision` to a `let` before
matching).
### Frontend (VideoPlayer.svelte)
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
- Listen for Android lifecycle background/foreground signals (via a bridge event
or existing visibility hooks) and:
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
audio).
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
returned position, restore play/pause state.
- **Follow the native-mode pitfall** (memory:
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
`onMount`. Keep the handoff logic out of that window.
- Dual-audio is the key regression risk: at every handoff exactly one of
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
*before* starting the next, mirroring the existing HLS cleanup discipline.
## Phasing
- **Phase 0 — De-risk (do first):**
- Confirm what actually triggers today's auto-PiP given video is on the
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
- Spike: obtain an audio-only stream URL for a video item and play it through
the native audio service; measure position accuracy and that WebView audio
is fully silenced (no dual audio).
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
commands + events.
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
teardown discipline.
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
carry-over, edge cases (calls, headphone unplug, autoplay-next during
background audio).
## Testing
- Rust: unit tests for the audio-only URL builder and the two commands
(`cargo test`, `bun run test:rust`).
- IPC param-naming integration tests for any new commands
(`bun run test -- tauriIntegration.test.ts`).
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
the handoff state machine (mirror the existing
`VideoPlayer.logic.test.ts`).
- Manual on-device matrix:
- toggle ON: home button → audio continues, video stops decoding; return →
video resumes at position; playing/paused preserved.
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
resumes.
- toggle OFF: background → PiP (unchanged).
- No dual audio at any transition. No audio leak after leaving the player.
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
- Autoplay-next fires correctly if an episode ends during background audio.
## Open questions
1. **Persist the toggle per user/series, or default OFF each session?**
(Recommend: remember last choice; series-level like the audio-track
preference is a nice-to-have.)
2. **Autoplay-next during background audio** — should the next episode start as
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
continue as audio-only.)
3. **Subtitles** are irrelevant in audio-only mode but must restore on
foreground — confirm they survive the `<video>` teardown/reload.
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
`onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
## Non-goals (v1)
- Linux background audio (desktop windows keep running unfocused; low value).
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
- Re-enabling the native ExoPlayer *video* surface path.
+1574 -655
View File
File diff suppressed because it is too large Load Diff
+45 -10
View File
@@ -690,24 +690,59 @@ flowchart TB
└─────────────────────────────────────────┘
```
### 9.2 Video Playback in Background
### 9.2 Video Playback in Background (Android — PiP & Background Audio)
Leaving the app while a **local video** is playing does not simply pause it.
What happens depends on which background behaviour is active. The two are
**mutually exclusive**, and both apply **only to locally-rendering video**
audio-only playback, library/menu browsing, and remote/cast sessions never
trigger PiP (see decision gate below).
```mermaid
flowchart TB
VideoPlaying[Video Playing] --> Background{User Action}
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
Background -->|Home Button| AutoPause[Automatically Pause]
Background -->|Screen Lock| AutoPause
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]
AutoPause --> SaveProgress[Save Progress]
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
Gate -->|Yes| Mode{Background mode armed?}
ShowNotification --> UserReturn{User Returns?}
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
UserReturn -->|Later| KeepPaused[Video Remains Paused]
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> &#40;reflects live player state&#41;]
ResumeVideo --> AskResume[Resume from Saved Position]
PiPWindow --> PiPReturn{User action}
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
PiPReturn -->|Close window| Stop[Playback stops]
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
```
**Key rules:**
- **Video-only gate.** Auto-PiP is guarded by the native `canEnterPip` check
(local video surface actively rendering). Audio playback and menu/library
browsing background normally; remote/cast sessions render nothing locally, so
a PiP window would be an empty box and is refused. *(UR-041, IR-026)*
- **Only one background behaviour at a time.** The background-audio toggle
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
audio service *or* floated in PiP, never both.
- **PiP controls track the player.** The play/pause RemoteAction in the PiP
window reflects the live player state and updates on every playback-state
change, not only when the button is pressed. *(DR-053)*
- **Non-disruptive transition.** ExoPlayer keeps rendering into the same
surface across enter/exit, so entering or leaving PiP never interrupts the
video; on exit the surface is re-fit to full-screen bounds. *(DR-053)*
**PiP window (Android):**
```
┌───────────────────┐
│ │
│ ▶ video frame │
│ │
│ [⏸] │ ← play/pause RemoteAction
└───────────────────┘
sized to the video's aspect ratio
```
---
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.0.16",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -28,7 +28,8 @@
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
"traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"release:notes": "bun run scripts/release-notes.ts"
},
"license": "MIT",
"dependencies": {
+3
View File
@@ -44,6 +44,9 @@ bun run build
# Step 2: Build Android APK
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..."
bun run tauri android build --apk true
else
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bun
/**
* release-notes.ts turn a commit range into capability-level release notes
* using the TRACES graph instead of raw commit subjects.
*
* Usage:
* bun run scripts/release-notes.ts [<range>]
* bun run scripts/release-notes.ts v0.0.15..HEAD
*
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
*
* How it works:
* 1. `git diff --name-only <range>` files the range changed.
* 2. Read each changed file's `TRACES:` comments requirement IDs.
* 3. Resolve IDs to descriptions from docs/requirements.md.
* 4. Group: UR Features, DR/IR Improvements. Deduped, so many commits
* touching one requirement collapse to one line.
*
* This is a drafting aid for docs/release-checklist.md review the output,
* it does not invent descriptions for untraced changes (those are listed
* separately so nothing is silently dropped).
*/
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
function sh(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function defaultRange(): string {
try {
const tag = sh("git describe --tags --abbrev=0");
return `${tag}..HEAD`;
} catch {
return ""; // no tags: fall through to whole-history diff
}
}
/** Map requirement ID → human description, parsed from docs/requirements.md. */
function loadRequirementDescriptions(): Map<string, string> {
const map = new Map<string, string>();
const text = readFileSync("docs/requirements.md", "utf8");
for (const line of text.split("\n")) {
const m = line.match(REQ_ROW_RE);
// First definition wins: the descriptive tables come before the later
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
}
return map;
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
}
/** Collect requirement IDs referenced by TRACES comments in the given files. */
function idsFromFiles(files: string[]): Set<string> {
const ids = new Set<string>();
for (const file of files) {
let content: string;
try {
content = readFileSync(file, "utf8");
} catch {
continue;
}
for (const trace of content.matchAll(TRACE_RE)) {
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
}
}
return ids;
}
function main() {
const range = process.argv[2] ?? defaultRange();
const descriptions = loadRequirementDescriptions();
const files = changedFiles(range);
const ids = idsFromFiles(files);
const features: string[] = []; // UR
const improvements: string[] = []; // DR / IR
const unknown: string[] = []; // traced but not in requirements.md
for (const id of [...ids].sort()) {
const desc = descriptions.get(id);
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
if (!desc) {
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
continue;
}
const line = `- ${desc} (${id})`;
if (id.startsWith("UR")) features.push(line);
else improvements.push(line);
}
const header = range || "(entire history — no tags found)";
const out: string[] = [`## Release notes — ${header}`, ""];
if (features.length) out.push("### ✨ Features", ...features, "");
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
if (unknown.length)
out.push(
"### ⚠️ Traced IDs missing from requirements.md",
...unknown.map((id) => `- ${id}`),
"",
);
const untraced = files.filter((f) => {
try {
return !/TRACES:/.test(readFileSync(f, "utf8"));
} catch {
return false;
}
});
if (untraced.length)
out.push(
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
...untraced.map((f) => `- ${f}`),
"",
);
if (!features.length && !improvements.length)
out.push("_No traced requirements in this range._", "");
console.log(out.join("\n"));
}
main();
+22
View File
@@ -41,6 +41,19 @@ if [ -f "$APP_GRADLE_SRC" ]; then
echo " Copied: app/build.gradle.kts"
fi
# AndroidManifest.xml. `tauri android init` regenerates gen/android from
# tauri.conf.json and would drop our hand-maintained entries (media playback
# service + permissions, hardware acceleration, picture-in-picture attributes
# on MainActivity), so this tracked copy is the source of truth and must be
# restored after a regen. Gradle reads ONLY the gen/ copy - there is no
# manifest-merger hook here, so this must be the complete manifest.
MANIFEST_SRC="$PROJECT_ROOT/src-tauri/android/src/main/AndroidManifest.xml"
MANIFEST_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/AndroidManifest.xml"
if [ -f "$MANIFEST_SRC" ]; then
cp "$MANIFEST_SRC" "$MANIFEST_DST"
echo " Copied: app/src/main/AndroidManifest.xml"
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.
@@ -65,6 +78,15 @@ if [ -d "$RES_SRC" ]; then
cp "$dir"/* "$RES_DST/$name/"
echo " Copied res: $name"
done
# values/ (themes.xml): status-bar styling that `tauri android init` does
# not generate. Previously this directory was tracked but never copied, so
# the theme customizations below never reached a build.
if [ -d "$RES_SRC/values" ]; then
mkdir -p "$RES_DST/values"
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
echo " Copied res: values"
fi
# 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
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
#
# .env is the single source of truth for local release signing. `tauri android
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
# from .env before every release build (this is the local mirror of what the CI
# workflow does from Gitea secrets).
#
# Required .env vars:
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE not found — cannot configure release signing." >&2
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
exit 1
fi
# Load .env without leaking it into the caller's environment beyond what we need.
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
exit 1
fi
mkdir -p "$(dirname "$PROPS")"
umask 077
cat > "$PROPS" <<EOF
storeFile=$ANDROID_KEYSTORE_FILE
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
@@ -9,6 +9,16 @@
-keep class com.dtourolle.jellytau.player.** { *; }
-keep class com.dtourolle.jellytau.security.** { *; }
# Picture-in-picture is driven from the WebView through an
# @JavascriptInterface bridge, so the only references to these methods live
# in JavaScript. R8 sees them as unused and would strip them, silently
# breaking the PiP button in release builds only.
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-39
View File
@@ -1,39 +0,0 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
+64 -2
View File
@@ -1,5 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Authoritative AndroidManifest for JellyTau.
NOTE: this is NOT a manifest-merger fragment. Gradle only ever reads
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
regenerates that file from tauri.conf.json - dropping everything below.
scripts/sync-android-sources.sh copies this file over the generated one,
so this is the full manifest and the single source of truth.
(An earlier version of this file was a partial <application> fragment on the
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
declared never reached any built APK. It is folded in properly below.)
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Enable hardware acceleration for video playback performance -->
<application android:hardwareAccelerated="true" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.jellytau"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Media playback service for lockscreen controls -->
<service
android:name="com.dtourolle.jellytau.player.JellyTauPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -22,6 +22,34 @@ class MainActivity : TauriActivity() {
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
/**
* Coarse override for whether backgrounding the app should auto-enter PiP.
*
* This is NOT what excludes audio/browsing/cast from PiP that is the
* PictureInPictureManager.canEnterPip guard, which requires a local video
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
* flag is only toggled by the background-audio feature (via
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
* auto-PiP stay mutually exclusive.
*/
@Volatile
private var autoEnterPipEnabled = true
/**
* Whether the user armed background-audio mode on the current video (UR-040).
* When true, leaving the app hands audio off to the native ExoPlayer audio
* service (frontend-driven) instead of entering PiP, and video decode stops.
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
*/
@Volatile
private var backgroundAudioEnabled = false
/**
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
* can dispatch DOM events into it (native frontend signalling).
*/
private var mediaWebView: WebView? = null
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -37,6 +65,75 @@ class MainActivity : TauriActivity() {
configureWebViewForMedia()
}
/**
* Called when the user leaves the app via Home or the gesture equivalent
* (but NOT via Back). This is the standard hook for auto-entering PiP so
* video keeps playing in a floating window instead of being backgrounded.
*
* TRACES: UR-041 | IR-026 | DR-053
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Never enter PiP while background-audio mode is armed — the two are mutually
// exclusive (the handoff runs from onStop instead).
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
PictureInPictureManager.canEnterPip(this)) {
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
PictureInPictureManager.enterPip(this)
}
}
/**
* The app is no longer visible (Home, app switch, or screen lock). When
* background-audio mode is armed, tell the frontend to hand video playback off
* to the native audio service. onStop (rather than onUserLeaveHint) is used
* because it fires on screen-lock too, which is the primary use case (UR-040).
*
* TRACES: UR-040 | IR-025
*/
override fun onStop() {
super.onStop()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-background")
}
}
/** The app is visible again — tell the frontend to resume WebView video. */
override fun onStart() {
super.onStart()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-foreground")
}
}
/**
* Dispatch a DOM CustomEvent into the WebView (native frontend). Mirrors the
* evaluateJavascript pattern already used to unmute video elements. Posted to
* the WebView thread; safe no-op if the WebView isn't found yet.
*/
private fun dispatchWebEvent(name: String) {
val webView = mediaWebView ?: run {
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
return
}
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
android.util.Log.d("MainActivity", "Dispatched web event: $name")
}
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: android.content.res.Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
android.util.Log.d("MainActivity", "PiP mode changed: $isInPictureInPictureMode")
PictureInPictureManager.onPipModeChanged(this, isInPictureInPictureMode)
}
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
@@ -55,6 +152,7 @@ class MainActivity : TauriActivity() {
}
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@@ -70,6 +168,52 @@ class MainActivity : TauriActivity() {
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
// methods are invoked on a WebView binder thread.
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun enterPip() {
handler.post { PictureInPictureManager.enterPip(this@MainActivity) }
}
/** Whether the PiP button should be offered in the player UI at all. */
@JavascriptInterface
fun isSupported(): Boolean {
return PictureInPictureManager.isPipSupported(this@MainActivity)
}
/** Whether entering PiP would work right now (local video playing). */
@JavascriptInterface
fun canEnterPip(): Boolean {
return PictureInPictureManager.canEnterPip(this@MainActivity)
}
/** Let the frontend opt out of auto-PiP (e.g. while casting). */
@JavascriptInterface
fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
// Add JavaScript interface for background-audio mode (UR-040). The frontend
// arms/disarms it via the player toggle; the Activity uses the flag in its
// lifecycle overrides to decide between the audio handoff and PiP.
webView.addJavascriptInterface(object : Any() {
/** Frontend arms/disarms background-audio mode for the current video. */
@JavascriptInterface
fun setEnabled(enabled: Boolean) {
backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
}
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -93,7 +237,6 @@ class MainActivity : TauriActivity() {
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
setRenderPriority(WebSettings.RenderPriority.HIGH)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
@@ -0,0 +1,312 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Rational
import android.view.ViewGroup
import android.webkit.WebView
import androidx.annotation.RequiresApi
import com.dtourolle.jellytau.player.JellyTauPlayer
/**
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
*
* TRACES: UR-041 | IR-026 | DR-053
*
* PiP shrinks the whole Activity into a floating window, so the only thing that
* should remain visible is the video SurfaceView that [VideoOverlayManager]
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
* hidden for the duration - it is opaque and sits *above* the surface, so
* leaving it visible would occlude the video entirely.
*
* Playback itself is untouched: ExoPlayer keeps rendering into the same surface
* across the transition, so entering and leaving PiP never interrupts the video.
*/
object PictureInPictureManager {
private const val TAG = "PictureInPictureManager"
/** Action for the play/pause RemoteAction shown inside the PiP window. */
private const val ACTION_MEDIA_CONTROL = "com.dtourolle.jellytau.PIP_MEDIA_CONTROL"
private const val EXTRA_CONTROL_TYPE = "control_type"
private const val CONTROL_PLAY = 1
private const val CONTROL_PAUSE = 2
/** Request codes must differ per action or the PendingIntents collapse into one. */
private const val REQUEST_PLAY = 101
private const val REQUEST_PAUSE = 102
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app.
*/
fun isPipSupported(activity: Activity): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.packageManager.hasSystemFeature(
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
)
}
/**
* Whether entering PiP right now makes sense: a native video must actually
* be playing locally. Audio-only playback and remote/cast sessions render
* nothing on this device, so a PiP window would be an empty black box.
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
return try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "canEnterPip check failed", e)
false
}
}
/**
* Enter picture-in-picture, sizing the window to the video's aspect ratio.
*
* @return true if the system accepted the transition.
*/
fun enterPip(activity: Activity): Boolean {
if (!canEnterPip(activity)) {
android.util.Log.d(TAG, "Not entering PiP: no local video playing")
return false
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return try {
val params = buildParams(activity)
val entered = activity.enterPictureInPictureMode(params)
android.util.Log.d(TAG, "enterPictureInPictureMode returned $entered")
entered
} catch (e: Exception) {
// IllegalStateException here means PiP is disallowed (e.g. the user
// turned it off in system settings). Never crash over it.
android.util.Log.e(TAG, "Failed to enter PiP", e)
false
}
}
/**
* Build PiP params: aspect ratio from the current video, plus a play/pause
* RemoteAction reflecting the live playback state.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildParams(activity: Activity): PictureInPictureParams {
val builder = PictureInPictureParams.Builder()
aspectRatioFor()?.let { builder.setAspectRatio(it) }
builder.setActions(listOf(buildPlayPauseAction(activity)))
return builder.build()
}
/**
* The video's aspect ratio, clamped to the range Android accepts.
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content.
*/
private fun aspectRatioFor(): Rational? {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return null
}
val surface = player.getSurfaceView() ?: return null
// The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
val width = surface.width
val height = surface.height
if (width <= 0 || height <= 0) return null
val ratio = width.toDouble() / height.toDouble()
val minRatio = 1.0 / 2.39
val maxRatio = 2.39
val clamped = ratio.coerceIn(minRatio, maxRatio)
// Scale to integers; Rational(width, height) directly can overflow for
// large surfaces, and the clamped value may not match the raw pixels.
return Rational((clamped * 1000).toInt(), 1000)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
Quad(
android.R.drawable.ic_media_pause,
"Pause",
CONTROL_PAUSE,
REQUEST_PAUSE
)
} else {
Quad(
android.R.drawable.ic_media_play,
"Play",
CONTROL_PLAY,
REQUEST_PLAY
)
}
val intent = Intent(ACTION_MEDIA_CONTROL)
.putExtra(EXTRA_CONTROL_TYPE, controlType)
// Explicit package keeps the broadcast internal to the app.
.setPackage(activity.packageName)
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val pendingIntent = android.app.PendingIntent.getBroadcast(
activity,
requestCode,
intent,
flags
)
return RemoteAction(
Icon.createWithResource(activity, iconRes),
title,
title,
pendingIntent
)
}
private data class Quad<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D
)
/**
* Refresh the PiP window's action button so it tracks play/pause state
* while the window is open. Safe to call when not in PiP (no-op).
*/
fun updatePipActions(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (!activity.isInPictureInPictureMode) return
try {
activity.setPictureInPictureParams(buildParams(activity))
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to update PiP actions", e)
}
}
/**
* Called from MainActivity.onPictureInPictureModeChanged.
*
* Entering: hide the WebView so only the video surface shows, and register
* the receiver backing the PiP play/pause button.
* Leaving: restore the WebView and unregister.
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
hideWebView(activity)
registerReceiver(activity)
} else {
unregisterReceiver(activity)
showWebView()
// The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized.
try {
JellyTauPlayer.getInstance().fitSurfaceToScreen()
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to re-fit surface after PiP", e)
}
}
}
private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView)
if (webView == null) {
android.util.Log.w(TAG, "No WebView found to hide for PiP")
return
}
// GONE rather than INVISIBLE: the WebView is opaque, and GONE also stops
// it from consuming layout space in the shrunken window.
webView.visibility = android.view.View.GONE
hiddenWebView = webView
android.util.Log.d(TAG, "WebView hidden for PiP")
}
private fun showWebView() {
hiddenWebView?.let {
it.visibility = android.view.View.VISIBLE
android.util.Log.d(TAG, "WebView restored after PiP")
}
hiddenWebView = null
}
private fun registerReceiver(activity: Activity) {
if (receiver != null) return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
// Swap the button to reflect the new state.
updatePipActions(activity)
}
}
val filter = IntentFilter(ACTION_MEDIA_CONTROL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
activity.registerReceiver(r, filter)
}
receiver = r
android.util.Log.d(TAG, "PiP media control receiver registered")
}
private fun unregisterReceiver(activity: Activity) {
receiver?.let {
try {
activity.unregisterReceiver(it)
} catch (e: IllegalArgumentException) {
// Already unregistered - harmless.
}
}
receiver = null
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWebView(view.getChildAt(i))?.let { return it }
}
}
return null
}
}
@@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() {
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
// The scrubber is absolute; Rust owns the seek in absolute terms
// (in a background-audio handoff it rebuilds the stream at this
// StartTimeTicks). Send the absolute position as-is.
val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
@@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() {
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
// Base offset (ms) added to every position reported to the lockscreen
// MediaSession. During a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
// position RELATIVE to that point (starting at 0). The metadata duration,
// however, is the full absolute length — so without this base the scrubber
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
// position via setPositionOffset(); 0 for normal playback.
private var positionOffsetMs: Long = 0L
/**
* Set the base position offset (seconds) applied to lockscreen positions.
* Called by the native layer when entering/exiting a background-audio handoff.
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
*/
fun setPositionOffset(offsetSeconds: Double) {
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
}
/**
* Update the MediaSession metadata and playback state, plus the notification.
*
@@ -292,8 +314,8 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Update MediaSession playback state (position made absolute via the base offset).
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
@@ -322,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
+61 -16
View File
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
use crate::connectivity::ConnectivityMonitor;
use crate::jellyfin::http_client::HttpClient;
pub use session_verifier::SessionVerifier;
@@ -99,7 +99,10 @@ impl AuthManager {
}
/// Set the connectivity monitor (for marking server reachability)
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
pub fn set_connectivity_monitor(
&mut self,
monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
) {
self.connectivity_monitor = Some(monitor);
}
@@ -133,9 +136,17 @@ impl AuthManager {
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) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
log::info!(
"[AuthManager] Connected to server: {} ({})",
info.server_name,
info.version
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -181,7 +192,10 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(None, device_id);
// Build request manually for custom headers
let request = self.http_client.client.post(&endpoint)
let request = self
.http_client
.client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.json(&serde_json::json!({
@@ -192,19 +206,31 @@ impl AuthManager {
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| format!("Login 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());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
}
let auth_response: AuthenticateByNameResponse = response.json().await
let auth_response: AuthenticateByNameResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse login response: {}", e))?;
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
log::info!(
"[AuthManager] Login successful for user: {} ({})",
auth_response.user.name,
auth_response.user.id
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -243,13 +269,19 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request manually for custom headers
let request = self.http_client.client.get(&endpoint)
let request = self
.http_client
.client
.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| {
log::warn!("[AuthManager] Session verification failed: {}", e);
format!("Session verification failed: {}", e)
@@ -257,24 +289,34 @@ impl AuthManager {
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
// Mark server as unreachable for auth errors
if status.as_u16() == 401 || status.as_u16() == 403 {
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
monitor
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
.await;
}
}
return Err(format!("HTTP {}: {}", status, error_text));
}
let user_response: JellyfinUser = response.json().await
let user_response: JellyfinUser = response
.json()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))?;
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
log::info!(
"[AuthManager] Session verified successfully for: {}",
user_response.name
);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
@@ -306,7 +348,10 @@ impl AuthManager {
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request
let request = self.http_client.client.post(&endpoint)
let request = self
.http_client
.client
.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
+17 -5
View File
@@ -1,8 +1,8 @@
use serde::Serialize;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use super::{AuthManager, User};
@@ -65,7 +65,10 @@ impl SessionVerifier {
let session = auth_manager.get_session().await;
if let Some(session) = session {
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
log::debug!(
"[SessionVerifier] Verifying session for: {}",
session.username
);
// Verify the session
match auth_manager
@@ -113,7 +116,10 @@ impl SessionVerifier {
reason: "Session expired".to_string(),
};
if let Err(e) = app.emit("auth:needs-reauth", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
log::error!(
"[SessionVerifier] Failed to emit event: {}",
e
);
}
}
@@ -131,12 +137,18 @@ impl SessionVerifier {
message: e.clone(),
};
if let Err(e) = app.emit("auth:network-error", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
log::error!(
"[SessionVerifier] Failed to emit event: {}",
e
);
}
}
} else {
// Unknown error - log but don't invalidate
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
log::error!(
"[SessionVerifier] Unknown error during verification: {}",
e
);
}
}
}
+48 -18
View File
@@ -1,7 +1,11 @@
//! Authentication and session-lifecycle commands.
//!
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
use std::sync::Arc;
use tauri::State;
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
/// Wrapper for AuthManager to manage in Tauri state
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
log::info!("[AuthManager] Restoring session from storage...");
// Use the existing storage_get_active_session function
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
let active_session =
match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
// Store in AuthManager
auth_manager.0.set_session(Some(session.clone())).await;
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
log::info!(
"[AuthManager] Session restored for user: {} with normalized URL: {}",
session.username,
session.server_url
);
Ok(Some(session))
}
@@ -80,7 +89,10 @@ pub async fn auth_login(
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
let result = auth_manager
.0
.login(&server_url, &username, &password, &device_id)
.await?;
// Create session from auth result with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<bool, String> {
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
match auth_manager
.0
.verify_session(&server_url, &user_id, &access_token, &device_id)
.await
{
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[AuthCommands] Session verification failed: {}", e);
@@ -138,7 +154,10 @@ pub async fn auth_logout(
drop(verifier_guard);
// Call Jellyfin logout endpoint
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
auth_manager
.0
.logout(&server_url, &access_token, &device_id)
.await?;
// Clear session
auth_manager.0.set_session(None).await;
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
// Get current session to extract server_url and username
let session = auth_manager.0.get_session().await
let session = auth_manager
.0
.get_session()
.await
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
// Re-login with stored credentials
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
let result = auth_manager
.0
.login(
&session.server_url,
&session.username,
&password,
&device_id,
)
.await?;
// Update session with new token
let updated_session = Session {
+91 -28
View File
@@ -1,5 +1,7 @@
//! Tauri commands for the offline "browse & queue" feature.
//!
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
//!
//! Two backend pieces support browsing the full server catalog while offline
//! and queueing downloads that fire on reconnect:
//!
@@ -17,8 +19,8 @@ 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::repository::RepositoryManagerWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::repository::types::GetItemsOptions;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -79,7 +81,10 @@ pub async fn sync_full_catalog(
};
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
info!(
"[Catalog] Full sync starting across {} libraries",
libraries.len()
);
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
@@ -104,7 +109,10 @@ pub async fn sync_full_catalog(
items_cached += items.len();
}
Err(e) => {
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
warn!(
"[Catalog] Failed to sync library '{}': {:?}",
library.name, e
);
libraries_failed += 1;
}
}
@@ -159,6 +167,20 @@ pub async fn catalog_sync_status(
Ok(CatalogSyncStatus { last_synced_at })
}
/// Control whether offline library queries reveal the full synced catalog
/// (greyed-out, non-downloaded media) or only downloaded/local media.
///
/// The frontend calls this from the "Show all server media" toggle: pass `true`
/// when online, or when offline with the toggle on; pass `false` when offline
/// with the toggle off so library pages show downloaded media only. Fixes the
/// bug where offline library pages showed every server item regardless of the
/// toggle.
#[tauri::command]
#[specta::specta]
pub fn set_show_server_catalog(show: bool) {
crate::repository::offline::set_include_catalog_browse(show);
}
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumeQueuedResult {
@@ -194,10 +216,16 @@ where
.map_err(|e| e.to_string())?;
if rows.is_empty() {
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
return Ok(ResumeQueuedResult {
resolved: 0,
failed: 0,
});
}
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
info!(
"[Catalog] Resolving {} offline-queued downloads on reconnect",
rows.len()
);
let mut resolved = 0usize;
let mut failed = 0usize;
@@ -226,7 +254,10 @@ where
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);
warn!(
"[Catalog] Failed to persist URL for download {}: {}",
download_id, e
);
failed += 1;
}
}
@@ -271,6 +302,21 @@ pub async fn resume_queued_downloads(
(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(
@@ -280,17 +326,22 @@ pub async fn resume_queued_downloads(
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,
))
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);
warn!(
"[Catalog] Failed to resolve audio URL for {}: {:?}",
item_id, e
);
None
}
}
@@ -312,7 +363,10 @@ pub async fn resume_queued_downloads(
pump_download_queue(app, db_service, active_downloads).await;
}
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
info!(
"[Catalog] Resume complete: {} resolved, {} failed",
resolved, failed
);
Ok(ResumeQueuedResult { resolved, failed })
}
@@ -355,14 +409,21 @@ mod tests {
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),
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>) {
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())],
@@ -382,11 +443,12 @@ mod tests {
// 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();
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);
@@ -426,12 +488,13 @@ mod tests {
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();
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;
+5 -1
View File
@@ -1,6 +1,10 @@
//! Server-reachability / connectivity commands.
//!
//! TRACES: UR-043 | IR-027 | DR-055
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
use std::sync::Arc;
use tauri::State;
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
/// Wrapper for ConnectivityMonitor managed state
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
+3 -2
View File
@@ -1,11 +1,12 @@
//! Tauri commands for unit conversions and formatting
//!
//! TRACES: UR-005 | DR-009
//!
//! These commands expose conversion utilities to the frontend,
//! allowing centralized conversion logic in Rust.
use crate::utils::conversions::{
format_time, format_time_long, calculate_progress,
ticks_to_seconds, percent_to_volume,
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
};
/// Format time in seconds to MM:SS display string
+4 -1
View File
@@ -81,7 +81,10 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
/// TRACES: UR-009 | DR-011
#[tauri::command]
#[specta::specta]
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
pub async fn device_set_id(
device_id: String,
db: State<'_, DatabaseWrapper>,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
+246 -67
View File
@@ -2,14 +2,14 @@
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::{Manager, State};
use log::{debug, error, info, warn};
use super::{DatabaseWrapper, SmartCacheWrapper};
use crate::download::{DownloadInfo, DownloadManager};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use super::{DatabaseWrapper, SmartCacheWrapper};
// Cohesive command clusters in their own submodules, re-exported so the command
// names remain at `commands::download::*` (invoke_handler unchanged).
@@ -111,7 +111,13 @@ pub async fn download_item_and_start(
request: DownloadItemAndStartRequest,
) -> Result<i64, String> {
let DownloadItemAndStartRequest {
item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name,
item_id,
user_id,
stream_url,
target_dir,
item_name,
artist_name,
album_name,
} = request;
// Sanitize filename
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
@@ -132,7 +138,8 @@ pub async fn download_item_and_start(
album_name,
expected_size: None,
},
).await?;
)
.await?;
// Start the download immediately
start_download(
@@ -142,7 +149,8 @@ pub async fn download_item_and_start(
download_id,
stream_url,
target_dir,
).await?;
)
.await?;
Ok(download_id)
}
@@ -156,7 +164,15 @@ pub async fn download_item(
request: DownloadItemRequest,
) -> Result<i64, String> {
let DownloadItemRequest {
item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size,
item_id,
user_id,
file_path,
mime_type,
priority,
item_name,
artist_name,
album_name,
expected_size,
} = request;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -172,18 +188,24 @@ pub async fn download_item(
};
// Check if we have space
let can_download = cache_arc.can_download_async(&db_service, &user_id, size as u64).await;
let can_download = cache_arc
.can_download_async(&db_service, &user_id, size as u64)
.await;
if !can_download {
warn!("Storage limit reached. Attempting to free space...");
// Try to evict LRU items to make space
match cache_arc.evict_lru_async(&db_service, &user_id, size as u64).await {
match cache_arc
.evict_lru_async(&db_service, &user_id, size as u64)
.await
{
Ok(freed) if freed > 0 => {
info!("Freed {} bytes, proceeding with download", freed);
}
Ok(_) => {
let storage_limit = cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
let storage_limit =
cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
return Err(format!(
"Storage limit reached ({} bytes). Unable to free enough space.",
storage_limit
@@ -220,7 +242,10 @@ pub async fn download_item(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the download ID by unique constraint columns
// NOTE: last_insert_rowid() doesn't work reliably with UPSERT - it only updates on INSERT, not UPDATE
@@ -291,12 +316,18 @@ pub async fn download_album(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(track_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(track_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -317,8 +348,17 @@ pub async fn download_video(
request: DownloadVideoRequest,
) -> Result<i64, String> {
let DownloadVideoRequest {
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
series_name, season_name, episode_number, season_number,
item_id,
user_id,
file_path,
mime_type,
priority,
item_name,
quality_preset,
series_name,
season_name,
episode_number,
season_number,
} = request;
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
@@ -358,7 +398,10 @@ pub async fn download_video(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
// Query for the download ID by unique constraint columns
let id_query = Query::with_params(
@@ -403,7 +446,13 @@ pub async fn download_series(
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
.query_many(episodes_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.await
.map_err(|e| e.to_string())?;
@@ -413,7 +462,9 @@ pub async fn download_series(
// Queue each episode with descending priority (first episodes download first)
// Priority starts high and decreases so earlier episodes finish first
let total_episodes = episodes.len() as i32;
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in episodes.into_iter().enumerate() {
for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
episodes.into_iter().enumerate()
{
let priority = 1000 - idx as i32; // High priority for first episodes
// Create path like: videos/SeriesName/S01E01_Title.mp4
@@ -425,7 +476,12 @@ pub async fn download_series(
episode_num,
sanitize_filename(&episode_name)
);
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
let file_path = format!(
"{}/{}/{}",
base_path,
sanitize_filename(&series_name),
file_name
);
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
@@ -450,17 +506,29 @@ pub async fn download_series(
QueryParam::String(episode_name),
QueryParam::String(quality.clone()),
QueryParam::String(series_name.clone()),
season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
season_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
episode_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
season_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(episode_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -471,7 +539,10 @@ pub async fn download_series(
download_ids.push(download_id);
}
info!("[download_series] Queued {} episodes for series '{}'", total_episodes, series_name);
info!(
"[download_series] Queued {} episodes for series '{}'",
total_episodes, series_name
);
Ok(download_ids)
}
@@ -524,7 +595,12 @@ pub async fn download_season(
episode_num,
sanitize_filename(&episode_name)
);
let file_path = format!("{}/{}/{}", base_path, sanitize_filename(&series_name), file_name);
let file_path = format!(
"{}/{}/{}",
base_path,
sanitize_filename(&series_name),
file_name
);
let insert_query = Query::with_params(
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
@@ -550,11 +626,17 @@ pub async fn download_season(
],
);
db_service.execute(insert_query).await.map_err(|e| e.to_string())?;
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
let id_query = Query::with_params(
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
vec![QueryParam::String(episode_id), QueryParam::String(user_id.clone())],
vec![
QueryParam::String(episode_id),
QueryParam::String(user_id.clone()),
],
);
let download_id: i64 = db_service
@@ -565,11 +647,15 @@ pub async fn download_season(
download_ids.push(download_id);
}
info!("[download_season] Queued {} episodes for {} - {}", download_ids.len(), series_name, season_name);
info!(
"[download_season] Queued {} episodes for {} - {}",
download_ids.len(),
series_name,
season_name
);
Ok(download_ids)
}
/// Helper to compute download statistics from a list of downloads
#[allow(dead_code)]
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
@@ -674,7 +760,10 @@ pub async fn get_downloads(
/// Pause a download
#[tauri::command]
#[specta::specta]
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn pause_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -692,7 +781,10 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
/// Resume a paused download
#[tauri::command]
#[specta::specta]
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn resume_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -738,13 +830,20 @@ pub async fn cancel_download(
vec![QueryParam::Int64(download_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
// Unregister from download manager (in case it was active)
{
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.unregister_download(download_id);
info!("Cancelled download {}. Active downloads: {}", download_id, manager.active_count());
info!(
"Cancelled download {}. Active downloads: {}",
download_id,
manager.active_count()
);
}
// Delete partial file if exists
@@ -800,7 +899,10 @@ pub async fn mark_download_failed(
let query = Query::with_params(
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
vec![QueryParam::String(error_message), QueryParam::Int64(download_id)],
vec![
QueryParam::String(error_message),
QueryParam::Int64(download_id),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
@@ -834,7 +936,10 @@ pub async fn start_download(
})?;
if !manager.can_start_download() {
warn!("Cannot start download: maximum concurrent downloads ({}) reached", manager.max_concurrent());
warn!(
"Cannot start download: maximum concurrent downloads ({}) reached",
manager.max_concurrent()
);
debug!(" Active downloads: {}", manager.active_count());
return Err(format!(
"Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
@@ -845,12 +950,19 @@ pub async fn start_download(
// Register this download as active
let registered = manager.register_download(download_id);
if !registered {
warn!("Failed to register download {}: already registered or limit reached", download_id);
warn!(
"Failed to register download {}: already registered or limit reached",
download_id
);
return Err("Download already in progress or limit reached".to_string());
}
info!("Download {} registered. Active downloads: {}/{}",
download_id, manager.active_count(), manager.max_concurrent());
info!(
"Download {} registered. Active downloads: {}/{}",
download_id,
manager.active_count(),
manager.max_concurrent()
);
}
// Get download info from DB
@@ -868,21 +980,23 @@ pub async fn start_download(
);
let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.query_one(info_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.await
.map_err(|e| {
error!("Failed to query download info: {}", e);
e.to_string()
})?;
debug!(" Retrieved: item_id={}, file_path={}, file_size={:?}", item_id, file_path, file_size);
debug!(
" Retrieved: item_id={}, file_path={}, file_size={:?}",
item_id, file_path, file_size
);
// Make a HEAD request to get the file size from Content-Length header
debug!("Making HEAD request to get file size...");
let head_response = reqwest::Client::new()
.head(&stream_url)
.send()
.await;
let head_response = reqwest::Client::new().head(&stream_url).send().await;
let file_size_from_server = match head_response {
Ok(response) => {
@@ -893,7 +1007,11 @@ pub async fn start_download(
.and_then(|v| v.parse::<i64>().ok());
if let Some(size) = size {
debug!(" Got file size from server: {} bytes ({} MB)", size, size / 1024 / 1024);
debug!(
" Got file size from server: {} bytes ({} MB)",
size,
size / 1024 / 1024
);
} else {
warn!(" Server didn't provide Content-Length header");
}
@@ -929,7 +1047,10 @@ pub async fn start_download(
)
};
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
db_service
.execute(update_query)
.await
.map_err(|e| e.to_string())?;
// Emit started event
let started_event = DownloadEvent::Started {
@@ -937,7 +1058,10 @@ pub async fn start_download(
item_id: item_id.clone(),
};
debug!("Emitting download-event: {:?}", started_event);
debug!(" Serialized: {}", serde_json::to_string(&started_event).unwrap_or_default());
debug!(
" Serialized: {}",
serde_json::to_string(&started_event).unwrap_or_default()
);
match app.emit("download-event", started_event) {
Ok(_) => debug!(" Event emitted successfully"),
Err(e) => error!(" Event emit failed: {:?}", e),
@@ -998,7 +1122,10 @@ pub async fn enqueue_download(
QueryParam::Int64(download_id),
],
);
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
db_service
.execute(update_query)
.await
.map_err(|e| e.to_string())?;
// Kick the pump: it will start as many pending downloads as there are slots.
let active_downloads = {
@@ -1056,7 +1183,9 @@ pub async fn enqueue_video_downloads(
};
// Build the transcode URL (pure URL builder, no server round-trip).
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
let stream_url = repo
.as_ref()
.get_video_download_url(&item_id, &quality, None);
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
@@ -1067,7 +1196,10 @@ pub async fn enqueue_video_downloads(
],
);
if let Err(e) = db_service.execute(update_query).await {
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
warn!(
"[enqueue_video] Failed to persist URL for download {}: {}",
download_id, e
);
}
}
@@ -1137,7 +1269,13 @@ pub(crate) async fn pump_download_queue(
let candidates: Vec<(i64, String, String, String, String)> = match db_service
.query_many(next_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})
.await
{
@@ -1189,7 +1327,10 @@ pub(crate) async fn pump_download_queue(
vec![QueryParam::Int64(download_id)],
);
if let Err(e) = db_service.execute(update_query).await {
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
error!(
"[pump] Failed to mark download {} downloading: {}",
download_id, e
);
if let Ok(mut a) = active_downloads.lock() {
a.remove(&download_id);
}
@@ -1228,8 +1369,8 @@ fn spawn_download_worker(
target_path: std::path::PathBuf,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::{DownloadTask, DownloadWorker};
use crate::download::events::DownloadEvent;
use crate::download::{DownloadTask, DownloadWorker};
use tauri::Emitter;
let task = DownloadTask {
@@ -1265,7 +1406,11 @@ fn spawn_download_worker(
// Free the slot before pumping so the next download can take it.
if let Ok(mut active) = active_downloads.lock() {
active.remove(&download_id);
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
debug!(
" Unregistered download {}. Active downloads: {}",
download_id,
active.len()
);
}
// The pump runs downloads in the background, so the terminal status MUST
@@ -1281,7 +1426,10 @@ fn spawn_download_worker(
let database = match db.0.lock() {
Ok(d) => d,
Err(e) => {
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
error!(
"[pump] Failed to lock database after download {}: {}",
download_id, e
);
return;
}
};
@@ -1290,7 +1438,10 @@ fn spawn_download_worker(
match result {
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(
@@ -1305,7 +1456,10 @@ fn spawn_download_worker(
],
);
if let Err(e) = db_service.execute(update).await {
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
error!(
"[pump] Failed to persist completed status for download {}: {}",
download_id, e
);
}
let completed_event = DownloadEvent::Completed {
@@ -1329,7 +1483,10 @@ fn spawn_download_worker(
],
);
if let Err(db_err) = db_service.execute(update).await {
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
error!(
"[pump] Failed to persist failed status for download {}: {}",
download_id, db_err
);
}
let failed_event = DownloadEvent::Failed {
@@ -1352,7 +1509,10 @@ fn spawn_download_worker(
/// Delete a completed download
#[tauri::command]
#[specta::specta]
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
pub async fn delete_download(
db: State<'_, DatabaseWrapper>,
download_id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1376,7 +1536,10 @@ pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
vec![QueryParam::Int64(download_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
// Delete actual file if exists
if let Some(path) = file_path {
@@ -1413,8 +1576,12 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
episode_number: row.get(20)?,
season_number: row.get(21)?,
quality_preset: row.get(22)?,
media_type: row.get::<_, Option<String>>(23)?.unwrap_or_else(|| "audio".to_string()),
download_source: row.get::<_, Option<String>>(24)?.unwrap_or_else(|| "user".to_string()),
media_type: row
.get::<_, Option<String>>(23)?
.unwrap_or_else(|| "audio".to_string()),
download_source: row
.get::<_, Option<String>>(24)?
.unwrap_or_else(|| "user".to_string()),
})
}
@@ -1500,7 +1667,10 @@ pub async fn get_download_storage_stats(
/// Delete all downloads for a user
#[tauri::command]
#[specta::specta]
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
pub async fn delete_all_downloads(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1598,7 +1768,10 @@ pub async fn delete_album_downloads(
"SELECT d.file_path FROM downloads d
JOIN items i ON d.item_id = i.id
WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
vec![QueryParam::String(user_id.clone()), QueryParam::String(album_id.clone())],
vec![
QueryParam::String(user_id.clone()),
QueryParam::String(album_id.clone()),
],
);
let file_paths: Vec<String> = db_service
@@ -1665,7 +1838,6 @@ pub async fn set_max_concurrent_downloads(
Ok(())
}
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
#[cfg(test)]
mod tests {
@@ -1834,7 +2006,10 @@ mod tests {
)
.unwrap();
assert_eq!(status, "pending", "Status should be reset to pending after UPSERT");
assert_eq!(
status, "pending",
"Status should be reset to pending after UPSERT"
);
}
#[test]
@@ -2069,7 +2244,11 @@ mod tests {
.unwrap();
let status: String = conn
.query_row("SELECT status FROM downloads WHERE id = ?1", params![id], |row| row.get(0))
.query_row(
"SELECT status FROM downloads WHERE id = ?1",
params![id],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "downloading");
+6 -1
View File
@@ -1,4 +1,6 @@
//! Pinning commands - protect an item's cached metadata from cache clearing.
//!
//! TRACES: UR-044 | DR-056
use std::sync::Arc;
use tauri::State;
@@ -45,7 +47,10 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
/// Check if an item is pinned
#[tauri::command]
#[specta::specta]
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
pub async fn is_item_pinned(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<bool, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -1,7 +1,9 @@
//! Smart-cache statistics/config and album recommendation commands.
//!
//! TRACES: UR-045 | DR-057
use std::sync::Arc;
use log::info;
use std::sync::Arc;
use tauri::State;
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
+2 -2
View File
@@ -25,11 +25,11 @@ pub use device::*;
pub use download::*;
pub use offline::*;
pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*;
pub use player::*;
pub use playlist::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
+35 -15
View File
@@ -1,3 +1,7 @@
//! Playback-mode transfer commands (local ↔ remote).
//!
//! TRACES: UR-010 | DR-059
use std::sync::Arc;
use tauri::State;
@@ -105,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Get session info
match client.get_session(&session_id).await {
Ok(Some(session)) => {
let position_ticks = session.play_state.as_ref()
let position_ticks = session
.play_state
.as_ref()
.and_then(|ps| ps.position_ticks)
.unwrap_or(0);
let duration_ticks = session.now_playing_item.as_ref()
let duration_ticks = session
.now_playing_item
.as_ref()
.and_then(|item| item.run_time_ticks)
.unwrap_or(0);
let is_paused = session.play_state.as_ref()
let is_paused = session
.play_state
.as_ref()
.and_then(|ps| ps.is_paused)
.unwrap_or(true);
@@ -224,17 +237,20 @@ mod tests {
fn test_playback_mode_deserialization_from_frontend() {
// Test what frontend sends for Idle mode
let idle_json = r#"{"type":"idle"}"#;
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
let mode: PlaybackMode =
serde_json::from_str(idle_json).expect("Failed to deserialize idle");
assert_eq!(mode, PlaybackMode::Idle);
// Test what frontend sends for Local mode
let local_json = r#"{"type":"local"}"#;
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
let mode: PlaybackMode =
serde_json::from_str(local_json).expect("Failed to deserialize local");
assert_eq!(mode, PlaybackMode::Local);
// Test what frontend sends for Remote mode
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
let mode: PlaybackMode =
serde_json::from_str(remote_json).expect("Failed to deserialize remote");
match mode {
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
_ => panic!("Expected Remote mode"),
@@ -247,8 +263,8 @@ mod tests {
// Test Search context (the recently fixed issue)
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
let context: PlayTracksContext = serde_json::from_str(search_json)
.expect("Failed to deserialize search context");
let context: PlayTracksContext =
serde_json::from_str(search_json).expect("Failed to deserialize search context");
match context {
PlayTracksContext::Search { search_query } => {
assert_eq!(search_query, "test query");
@@ -257,11 +273,15 @@ mod tests {
}
// Test Playlist context
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
let context: PlayTracksContext = serde_json::from_str(playlist_json)
.expect("Failed to deserialize playlist context");
let playlist_json =
r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
let context: PlayTracksContext =
serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
match context {
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
PlayTracksContext::Playlist {
playlist_id,
playlist_name,
} => {
assert_eq!(playlist_id, "pl-123");
assert_eq!(playlist_name, "My Playlist");
}
@@ -270,8 +290,8 @@ mod tests {
// Test Custom context
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
let context: PlayTracksContext = serde_json::from_str(custom_json)
.expect("Failed to deserialize custom context");
let context: PlayTracksContext =
serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
match context {
PlayTracksContext::Custom { label } => {
assert_eq!(label, Some("Custom Queue".to_string()));
+32 -7
View File
@@ -1,5 +1,7 @@
//! Tauri commands for playback reporting operations
//!
//! TRACES: UR-025, UR-019 | IR-015, JA-010, JA-011, JA-012 | DR-028
//!
//! These commands provide frontend access to the Rust playback reporting system,
//! replacing the TypeScript implementation with native Rust reporting.
//!
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::jellyfin::client::JellyfinClient;
use crate::jellyfin::JellyfinConfig;
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
use crate::playback_reporting::{PlaybackContext, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Tauri state wrapper for PlaybackReporter
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
// Store in wrapper
*reporter_wrapper.0.lock().await = Some(reporter);
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
log::info!(
"[PlaybackReporter] Initialized successfully for user: {}",
user_id
);
Ok(())
}
@@ -205,7 +210,12 @@ mod tests {
};
// Verify enum variant can be created and pattern matched
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
if let PlaybackOperation::Start {
item_id,
position_ticks,
context,
} = operation
{
assert_eq!(item_id, "item-123");
assert_eq!(position_ticks, 15_000_000);
assert!(context.is_some());
@@ -225,7 +235,10 @@ mod tests {
context: None,
};
if let PlaybackOperation::Start { item_id, context, .. } = operation {
if let PlaybackOperation::Start {
item_id, context, ..
} = operation
{
assert_eq!(item_id, "item-789");
assert!(context.is_none());
} else {
@@ -241,7 +254,12 @@ mod tests {
is_paused: true,
};
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
if let PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
} = operation
{
assert_eq!(item_id, "item-999");
assert_eq!(position_ticks, 30_000_000);
assert!(is_paused);
@@ -272,7 +290,11 @@ mod tests {
position_ticks: 120_000_000,
};
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
if let PlaybackOperation::Stopped {
item_id,
position_ticks,
} = operation
{
assert_eq!(item_id, "item-111");
assert_eq!(position_ticks, 120_000_000);
} else {
@@ -364,7 +386,10 @@ mod tests {
};
let cloned = operation.clone();
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
if let PlaybackOperation::Progress {
item_id, is_paused, ..
} = cloned
{
assert_eq!(item_id, "item-clone");
assert!(is_paused);
} else {
File diff suppressed because it is too large Load Diff
+65 -26
View File
@@ -1,4 +1,6 @@
//! Queue manipulation commands (add / remove / move / skip).
//!
//! TRACES: UR-015 | DR-005, DR-020
use std::path::PathBuf;
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position);
info!(
"player_add_track_by_id called: track_id={}, position={}",
request.track_id, request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch track metadata via repository
info!("Fetching metadata for track {} via repository", request.track_id);
let track = repository.get_item(&request.track_id).await
info!(
"Fetching metadata for track {} via repository",
request.track_id
);
let track = repository
.get_item(&request.track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
// Check for local download first
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -188,23 +201,30 @@ pub async fn player_add_track_by_id(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -250,18 +270,25 @@ pub async fn player_add_tracks_by_ids(
) -> Result<QueueStatus, String> {
use crate::player::queue::AddPosition;
info!("player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(), request.position);
info!(
"player_add_tracks_by_ids called: {} tracks, position={}",
request.track_ids.len(),
request.position
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch metadata and build MediaItems for all tracks
let mut media_items = Vec::new();
for track_id in &request.track_ids {
info!("Fetching metadata for track {} via repository", track_id);
let track = repository.get_item(track_id).await
let track = repository
.get_item(track_id)
.await
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
// Check for local download first
@@ -275,7 +302,9 @@ pub async fn player_add_tracks_by_ids(
}
} else {
// Get stream URL from repository (works online/offline)
let stream_url = repository.get_audio_stream_url(&track.id).await
let stream_url = repository
.get_audio_stream_url(&track.id)
.await
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
MediaSource::Remote {
@@ -290,23 +319,30 @@ pub async fn player_add_tracks_by_ids(
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}))
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
@@ -339,7 +375,10 @@ pub async fn player_add_tracks_by_ids(
drop(queue_lock);
controller.emit_queue_changed();
info!("Successfully added {} tracks to queue", request.track_ids.len());
info!(
"Successfully added {} tracks to queue",
request.track_ids.len()
);
Ok(result)
}
+80 -16
View File
@@ -1,5 +1,7 @@
//! Remote Jellyfin session control commands (casting to another device).
//!
//! TRACES: UR-010, UR-046 | IR-012, IR-028, JA-022, JA-023, JA-025, JA-026 | DR-037, DR-058
//!
//! These thin command adapters forward control actions to the active Jellyfin
//! session via the player's configured `JellyfinClient`.
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
item_ids: Vec<String>,
start_index: usize,
) -> Result<(), String> {
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
log::info!(
"[RemoteSession] Playing {} items on session {} (start index: {})",
item_ids.len(),
session_id,
start_index
);
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
client.play_on_session(session_id, item_ids, start_index, None).await?;
client
.play_on_session(session_id, item_ids, start_index, None)
.await?;
log::info!("[RemoteSession] Successfully started playback on remote session");
Ok(())
} else {
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
Err(
"Jellyfin client not configured - please restart the app or log out and log back in"
.to_string(),
)
}
}
@@ -44,11 +60,19 @@ pub async fn remote_send_command(
session_id: String,
command: String,
) -> Result<(), String> {
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
log::info!(
"[RemoteSession] Sending command '{}' to session {}",
command,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
session_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
log::info!(
"[RemoteSession] Seeking to {} ticks on session {}",
position_ticks,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
session_id: String,
volume: i32,
) -> Result<(), String> {
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
log::info!(
"[RemoteSession] Setting volume to {} on session {}",
volume,
session_id
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
) -> Result<Vec<LmsSyncGroup>, String> {
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
master_mac: String,
slave_macs: Vec<String>,
) -> Result<(), String> {
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
log::info!(
"[LmsSync] Fusing zones: master={}, slaves={:?}",
master_mac,
slave_macs
);
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
let client_opt = {
let controller = player.0.lock().await;
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
controller
.jellyfin_client()
.lock()
.map_err(|e| e.to_string())?
.clone()
};
if let Some(client) = client_opt {
+2
View File
@@ -1,5 +1,7 @@
//! Media session state commands.
//!
//! TRACES: UR-005 | DR-009
//!
//! Read and dismiss the current media session (the Now Playing surface backing
//! lockscreen/notification controls).
@@ -1,4 +1,6 @@
//! Audio and video playback settings commands.
//!
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
use tauri::State;
+11 -4
View File
@@ -1,5 +1,7 @@
//! Sleep-timer and autoplay commands.
//!
//! TRACES: UR-026, UR-023 | DR-029, DR-047, DR-049
//!
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
//! logic, plus persistence of autoplay settings to the database.
@@ -7,8 +9,8 @@ use std::sync::Arc;
use tauri::State;
use super::{
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
PlayerStateWrapper,
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
PlayerStatus,
};
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
@@ -127,7 +129,9 @@ pub async fn player_play_next_episode(
let media_item = create_media_item(item, Some(&db)).await?;
let controller = player.0.lock().await;
controller.play_item(media_item).map_err(|e| e.to_string())?;
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
if let Some(repo) = repo {
controller.on_video_playback_ended(id, repo).await?
} else {
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
log::warn!(
"[Autoplay] No repository available for video autoplay (itemId: {})",
id
);
AutoplayDecision::Stop
}
} else {
+33 -12
View File
@@ -6,8 +6,8 @@
use log::debug;
use tauri::State;
use crate::repository::{MediaRepository, types::*};
use super::repository::RepositoryManagerWrapper;
use crate::repository::{types::*, MediaRepository};
/// Create a new playlist
#[tauri::command]
@@ -21,7 +21,8 @@ pub async fn playlist_create(
debug!("[PLAYLIST] create called: name={}", name);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
let ids = item_ids.unwrap_or_default();
repo.as_ref().create_playlist(&name, &ids)
repo.as_ref()
.create_playlist(&name, &ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -36,7 +37,8 @@ pub async fn playlist_delete(
) -> Result<(), String> {
debug!("[PLAYLIST] delete called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().delete_playlist(&playlist_id)
repo.as_ref()
.delete_playlist(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -50,9 +52,13 @@ pub async fn playlist_rename(
playlist_id: String,
name: String,
) -> Result<(), String> {
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
debug!(
"[PLAYLIST] rename called: id={}, name={}",
playlist_id, name
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().rename_playlist(&playlist_id, &name)
repo.as_ref()
.rename_playlist(&playlist_id, &name)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -67,7 +73,8 @@ pub async fn playlist_get_items(
) -> Result<Vec<PlaylistEntry>, String> {
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playlist_items(&playlist_id)
repo.as_ref()
.get_playlist_items(&playlist_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -81,9 +88,14 @@ pub async fn playlist_add_items(
playlist_id: String,
item_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
debug!(
"[PLAYLIST] add_items called: id={}, count={}",
playlist_id,
item_ids.len()
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
repo.as_ref()
.add_to_playlist(&playlist_id, &item_ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -97,9 +109,14 @@ pub async fn playlist_remove_items(
playlist_id: String,
entry_ids: Vec<String>,
) -> Result<(), String> {
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
debug!(
"[PLAYLIST] remove_items called: id={}, count={}",
playlist_id,
entry_ids.len()
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
repo.as_ref()
.remove_from_playlist(&playlist_id, &entry_ids)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -114,9 +131,13 @@ pub async fn playlist_move_item(
item_id: String,
new_index: u32,
) -> Result<(), String> {
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
debug!(
"[PLAYLIST] move_item called: playlist={}, item={}, index={}",
playlist_id, item_id, new_index
);
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
repo.as_ref()
.move_playlist_item(&playlist_id, &item_id, new_index)
.await
.map_err(|e| format!("{:?}", e))
}
+88 -33
View File
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
};
/// Repository handle manager
pub struct RepositoryManager {
@@ -81,8 +83,13 @@ pub async fn repository_create(
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
.with_connectivity(connectivity_reporter);
let online = OnlineRepository::new(
Arc::new(http_client),
server_url,
user_id.clone(),
access_token,
)
.with_connectivity(connectivity_reporter);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching libraries...");
repo.as_ref().get_libraries()
.await
.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
repo.as_ref().get_libraries().await.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
}
/// Get items in a container (library, folder, album, etc.)
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items(&parent_id, options)
repo.as_ref()
.get_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -183,7 +189,8 @@ pub async fn repository_get_item(
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
repo.as_ref()
.get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -200,7 +207,8 @@ pub async fn repository_jray_actors_at(
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)
repo.as_ref()
.get_jray_actors(&item_id, t)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -215,7 +223,8 @@ pub async fn repository_get_latest_items(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_latest_items(&parent_id, limit)
repo.as_ref()
.get_latest_items(&parent_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -235,7 +244,8 @@ pub async fn repository_get_resume_items(
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
repo.as_ref()
.get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
@@ -253,7 +263,8 @@ pub async fn repository_get_next_up_episodes(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
repo.as_ref()
.get_next_up_episodes(series_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -267,7 +278,8 @@ pub async fn repository_get_recently_played_audio(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_recently_played_audio(limit)
repo.as_ref()
.get_recently_played_audio(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -281,7 +293,8 @@ pub async fn repository_get_resume_movies(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_resume_movies(limit)
repo.as_ref()
.get_resume_movies(limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -296,7 +309,8 @@ pub async fn repository_get_rediscover_albums(
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
repo.as_ref()
.get_rediscover_albums(parent_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -310,7 +324,8 @@ pub async fn repository_get_genres(
parent_id: Option<String>,
) -> Result<Vec<Genre>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_genres(parent_id.as_deref())
repo.as_ref()
.get_genres(parent_id.as_deref())
.await
.map_err(|e| format!("{:?}", e))
}
@@ -363,8 +378,7 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let event = SearchUpdateEvent {
request_id,
result: merged,
@@ -376,7 +390,10 @@ pub async fn repository_search(
Err(e) => {
// Server failed — the cache results are already on screen, so
// just log. (Offline / unreachable server falls here.)
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
warn!(
"[Search] Server search failed, keeping cache results: {:?}",
e
);
}
}
});
@@ -393,7 +410,8 @@ pub async fn repository_get_playback_info(
item_id: String,
) -> Result<PlaybackInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playback_info(&item_id)
repo.as_ref()
.get_playback_info(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -421,6 +439,31 @@ pub async fn repository_get_video_stream_url(
.map_err(|e| format!("{:?}", e))
}
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// TRACES: UR-040 | JA-032 | UT-061
#[tauri::command]
#[specta::specta]
pub async fn repository_get_audio_only_stream_url_for_video(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_only_stream_url_for_video(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get audio stream URL for a track
#[tauri::command]
#[specta::specta]
@@ -489,7 +532,8 @@ pub async fn repository_report_playback_start(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_start(&item_id, position_ticks)
repo.as_ref()
.report_playback_start(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -504,7 +548,8 @@ pub async fn repository_report_playback_progress(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_progress(&item_id, position_ticks)
repo.as_ref()
.report_playback_progress(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -519,7 +564,8 @@ pub async fn repository_report_playback_stopped(
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
repo.as_ref()
.report_playback_stopped(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -551,7 +597,9 @@ pub fn repository_get_subtitle_url(
format: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
Ok(repo
.as_ref()
.get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
}
/// Get video download URL with quality preset
@@ -566,7 +614,9 @@ pub fn repository_get_video_download_url(
media_source_id: Option<String>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
Ok(repo
.as_ref()
.get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
}
/// Mark an item as favorite
@@ -578,7 +628,8 @@ pub async fn repository_mark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
repo.as_ref()
.mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -592,7 +643,8 @@ pub async fn repository_unmark_favorite(
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
repo.as_ref()
.unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -606,7 +658,8 @@ pub async fn repository_get_person(
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
repo.as_ref()
.get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -621,7 +674,8 @@ pub async fn repository_get_items_by_person(
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items_by_person(&person_id, options)
repo.as_ref()
.get_items_by_person(&person_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
@@ -636,7 +690,8 @@ pub async fn repository_get_similar_items(
limit: Option<usize>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_similar_items(&item_id, limit)
repo.as_ref()
.get_similar_items(&item_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
+2 -2
View File
@@ -1,9 +1,9 @@
//! TRACES: UR-010 | JA-021 | DR-037
use crate::jellyfin::client::SessionInfo;
use crate::session_poller::{PollingHint, SessionPollerManager};
use std::sync::Arc;
use tauri::State;
use crate::session_poller::{PollingHint, SessionPollerManager};
use crate::jellyfin::client::SessionInfo;
/// Tauri state wrapper for SessionPollerManager
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
+140 -50
View File
@@ -1,4 +1,6 @@
//! Tauri commands for database/storage operations
//!
//! TRACES: UR-002, UR-011, UR-012, UR-017, UR-019, UR-025, UR-047 | IR-013 | DR-012, DR-013, DR-022, DR-060
use std::sync::{Arc, Mutex};
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
use tauri::State;
use crate::credentials::CredentialStore;
use crate::storage::Database;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
use crate::storage::Database;
use crate::thumbnail::ThumbnailCache;
use super::SmartCacheWrapper;
@@ -86,7 +88,8 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
let db_path = database.path();
// Return the parent directory instead of the database file path
let storage_dir = db_path.parent()
let storage_dir = db_path
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?;
Ok(storage_dir.to_string_lossy().to_string())
@@ -160,13 +163,16 @@ pub async fn storage_save_server(
/// Get all saved servers
#[tauri::command]
#[specta::specta]
pub async fn storage_get_servers(db: State<'_, DatabaseWrapper>) -> Result<Vec<ServerInfo>, String> {
pub async fn storage_get_servers(
db: State<'_, DatabaseWrapper>,
) -> Result<Vec<ServerInfo>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
let query =
Query::new("SELECT id, name, url, version FROM servers ORDER BY last_connected_at DESC");
let servers = db_service
.query_many(query, |row| {
@@ -221,7 +227,10 @@ pub async fn storage_delete_server(
vec![QueryParam::String(server_id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -237,7 +246,10 @@ pub async fn storage_save_user(
username: String,
access_token: Option<String>,
) -> Result<bool, String> {
info!("storage_save_user called: id={}, server_id={}, username={}", id, server_id, username);
info!(
"storage_save_user called: id={}, server_id={}, username={}",
id, server_id, username
);
let (db_service, db_path) = {
let database = db.0.lock().map_err(|e| {
@@ -277,7 +289,10 @@ pub async fn storage_save_user(
"SELECT COUNT(*) FROM users WHERE id = ?",
vec![QueryParam::String(id.clone())],
);
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
let verify_count: i32 = db_service
.query_one(verify_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("VERIFY: {} users with id={} after insert", verify_count, id);
debug!("Database path: {:?}", db_path);
@@ -355,14 +370,20 @@ pub async fn storage_set_active_user(
// Deactivate ALL users globally (since we only connect to one server at a time)
let deactivate_query = Query::new("UPDATE users SET is_active = 0");
db_service.execute(deactivate_query).await.map_err(|e| e.to_string())?;
db_service
.execute(deactivate_query)
.await
.map_err(|e| e.to_string())?;
// Activate the specified user and update last_login_at
let activate_query = Query::with_params(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::String(user_id.clone())],
);
let rows_affected = db_service.execute(activate_query).await.map_err(|e| e.to_string())?;
let rows_affected = db_service
.execute(activate_query)
.await
.map_err(|e| e.to_string())?;
debug!("storage_set_active_user: {} rows affected", rows_affected);
@@ -372,7 +393,10 @@ pub async fn storage_set_active_user(
// Verify the user is now active
let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
let verify_count: i32 = db_service.query_one(verify_query, |row| row.get(0)).await.unwrap_or(-1);
let verify_count: i32 = db_service
.query_one(verify_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("VERIFY: {} active users after set_active", verify_count);
debug!("Database path: {:?}", db_path);
@@ -434,12 +458,21 @@ pub async fn storage_get_active_session(
// Debug: count total users and active users
let total_query = Query::new("SELECT COUNT(*) FROM users");
let total_users: i32 = db_service.query_one(total_query, |row| row.get(0)).await.unwrap_or(-1);
let total_users: i32 = db_service
.query_one(total_query, |row| row.get(0))
.await
.unwrap_or(-1);
let active_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
let active_users: i32 = db_service.query_one(active_query, |row| row.get(0)).await.unwrap_or(-1);
let active_users: i32 = db_service
.query_one(active_query, |row| row.get(0))
.await
.unwrap_or(-1);
debug!("Database state: {} total users, {} active users", total_users, active_users);
debug!(
"Database state: {} total users, {} active users",
total_users, active_users
);
debug!("Database path: {:?}", db_path);
// Find active user with their server info, ordered by most recently logged in
@@ -449,18 +482,21 @@ pub async fn storage_get_active_session(
JOIN servers s ON u.server_id = s.id
WHERE u.is_active = 1
ORDER BY u.last_login_at DESC
LIMIT 1"
LIMIT 1",
);
let result = db_service.query_optional(session_query, |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
}).await.map_err(|e| e.to_string())?;
let result = db_service
.query_optional(session_query, |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
})
.await
.map_err(|e| e.to_string())?;
match result {
Some((user_id, username, server_id, server_url, server_name)) => {
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
server_name,
access_token,
}))
},
}
Err(e) => {
// Token not found or error - session is invalid
warn!("Failed to get token from secure storage: {:?}", e);
@@ -638,8 +674,12 @@ pub async fn storage_update_playback_context(
QueryParam::String(user_id.clone()),
QueryParam::String(item_id.clone()),
QueryParam::Int64(position_ticks),
context_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
context_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
context_type
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
context_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
],
);
@@ -721,14 +761,23 @@ pub async fn storage_mark_played(
});
if !tracks.is_empty() {
info!("Auto-queueing {} tracks from album for download", tracks.len());
info!(
"Auto-queueing {} tracks from album for download",
tracks.len()
);
// Queue each track with high priority (50) and mark as auto-downloaded
for (track_id, track_name, artist_name, album_name) in tracks {
// Generate a sanitized file path (simplified version)
let sanitized_name = track_name
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == ' ' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
@@ -1123,7 +1172,10 @@ pub async fn storage_search_items(
limit_clause
);
let query_obj = Query::with_params(sql, vec![QueryParam::String(server_id), QueryParam::String(fts_query)]);
let query_obj = Query::with_params(
sql,
vec![QueryParam::String(server_id), QueryParam::String(fts_query)],
);
let items = db_service
.query_many(query_obj, row_to_cached_item)
@@ -1181,7 +1233,8 @@ pub async fn storage_save_item(
};
// Generate sort_name from name (remove leading "The ", "A ", etc.)
let sort_name = item.name
let sort_name = item
.name
.strip_prefix("The ")
.or_else(|| item.name.strip_prefix("A "))
.or_else(|| item.name.strip_prefix("An "))
@@ -1202,28 +1255,66 @@ pub async fn storage_save_item(
vec![
QueryParam::String(item.id),
QueryParam::String(server_id),
item.library_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.parent_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.library_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.parent_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
QueryParam::String(item.name),
QueryParam::String(sort_name),
QueryParam::String(item.item_type),
item.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.genres.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.runtime_ticks.map(QueryParam::Int64).unwrap_or(QueryParam::Null),
item.production_year.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.community_rating.map(QueryParam::Float).unwrap_or(QueryParam::Null),
item.official_rating.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album_artist.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.artists.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.series_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.season_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
item.parent_index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
item.overview
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.genres
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.runtime_ticks
.map(QueryParam::Int64)
.unwrap_or(QueryParam::Null),
item.production_year
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
item.community_rating
.map(QueryParam::Float)
.unwrap_or(QueryParam::Null),
item.official_rating
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.primary_image_tag
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.album_artist
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.artists
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.index_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
item.series_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.series_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.season_id
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.season_name
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
item.parent_index_number
.map(QueryParam::Int)
.unwrap_or(QueryParam::Null),
],
);
@@ -1256,7 +1347,6 @@ pub async fn storage_get_pending_sync_count(
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
+43 -24
View File
@@ -1,13 +1,14 @@
//! Person/cast metadata cache commands.
//!
//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Cached person info returned to frontend
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
QueryParam::String(person.id),
QueryParam::String(person.server_id),
QueryParam::String(person.name),
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
person
.overview
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.primary_image_tag
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.premiere_date
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
person
.end_date
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
],
);
@@ -117,25 +130,32 @@ pub async fn storage_save_item_people(
let associations_clone = associations.clone();
// Use transaction for batch insert
db_service.transaction(move |tx| {
for assoc in &associations_clone {
let query = Query::with_params(
"INSERT OR REPLACE INTO item_people (
db_service
.transaction(move |tx| {
for assoc in &associations_clone {
let query = Query::with_params(
"INSERT OR REPLACE INTO item_people (
item_id, person_id, server_id, person_type, role, sort_order, synced_at
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
vec![
QueryParam::String(assoc.item_id.clone()),
QueryParam::String(assoc.person_id.clone()),
QueryParam::String(assoc.server_id.clone()),
QueryParam::String(assoc.person_type.clone()),
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::Int(assoc.sort_order),
],
);
tx.execute(query)?;
}
Ok(())
}).await.map_err(|e| e.to_string())?;
vec![
QueryParam::String(assoc.item_id.clone()),
QueryParam::String(assoc.person_id.clone()),
QueryParam::String(assoc.server_id.clone()),
QueryParam::String(assoc.person_type.clone()),
assoc
.role
.clone()
.map(QueryParam::String)
.unwrap_or(QueryParam::Null),
QueryParam::Int(assoc.sort_order),
],
);
tx.execute(query)?;
}
Ok(())
})
.await
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
Ok(people)
}
@@ -1,13 +1,14 @@
//! Per-series preferred audio track commands.
//!
//! TRACES: UR-021 | DR-024
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Audio track preference for a series
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+44 -26
View File
@@ -1,9 +1,11 @@
//! Thumbnail cache and image-URL commands.
//!
//! TRACES: UR-007 | JA-028 | DR-016
use std::sync::{Arc, OnceLock};
use tokio::sync::Semaphore;
use serde::Deserialize;
use std::sync::{Arc, OnceLock};
use tauri::State;
use tokio::sync::Semaphore;
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
use crate::commands::repository::RepositoryManagerWrapper;
@@ -11,7 +13,6 @@ use crate::repository::types::{ImageOptions, ImageType};
use crate::repository::MediaRepository;
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
/// Get cached thumbnail path, returns None if not cached
/// Also updates last_accessed timestamp for LRU tracking
#[tauri::command]
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
Arc::new(database.service())
};
let result = thumbnail_cache.0
let result = thumbnail_cache
.0
.get_cached_path(db_service, &item_id, &image_type, &tag)
.await
.map(|p| p.to_string_lossy().to_string());
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
Arc::new(database.service())
};
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
let path = thumbnail_cache
.0
.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
.await?;
Ok(path.to_string_lossy().to_string())
}
@@ -179,7 +184,7 @@ pub async fn image_get_url(
repository_handle: String,
request: GetImageRequest,
) -> Result<String, String> {
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use std::fs;
let tag = request.tag.as_deref().unwrap_or("default");
@@ -191,14 +196,18 @@ pub async fn image_get_url(
};
// Check cache first
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
db_service.clone(),
&request.item_id,
&request.image_type,
tag,
).await {
let image_data = fs::read(&cached_path)
.map_err(|e| format!("Failed to read cached image: {}", e))?;
if let Some(cached_path) = thumbnail_cache
.0
.get_cached_path(
db_service.clone(),
&request.item_id,
&request.image_type,
tag,
)
.await
{
let image_data =
fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
@@ -206,10 +215,14 @@ pub async fn image_get_url(
// Not cached — fetch from server and cache.
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
let _permit = image_semaphore().acquire().await
let _permit = image_semaphore()
.acquire()
.await
.map_err(|_| "Image download semaphore closed".to_string())?;
let repository = repository_manager.0.get(&repository_handle)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
let image_type_enum = match request.image_type.as_str() {
@@ -229,18 +242,23 @@ pub async fn image_get_url(
};
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
let image_data = repository.download_bytes(&server_url).await
let image_data = repository
.download_bytes(&server_url)
.await
.map_err(|e| format!("Failed to download image: {}", e))?;
let cached_path = thumbnail_cache.0.save_thumbnail(
db_service,
&request.item_id,
&request.image_type,
tag,
&image_data,
request.max_width.map(|w| w as i32),
request.max_height.map(|h| h as i32),
).await?;
let cached_path = thumbnail_cache
.0
.save_thumbnail(
db_service,
&request.item_id,
&request.image_type,
tag,
&image_data,
request.max_width.map(|w| w as i32),
request.max_height.map(|h| h as i32),
)
.await?;
let base64_data = BASE64.encode(&image_data);
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
+6 -9
View File
@@ -53,7 +53,10 @@ pub async fn sync_queue_mutation(
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
let id = db_service
.last_insert_rowid()
.await
.map_err(|e| e.to_string())?;
Ok(id)
}
@@ -110,10 +113,7 @@ pub async fn sync_get_pending(
/// Mark a sync operation as in progress
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_processing(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
@@ -131,10 +131,7 @@ pub async fn sync_mark_processing(
/// Mark a sync operation as completed
#[tauri::command]
#[specta::specta]
pub async fn sync_mark_completed(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
+35 -10
View File
@@ -1,9 +1,9 @@
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
@@ -170,9 +170,15 @@ impl ConnectivityReporter {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
log::error!(
"[ConnectivityMonitor] Failed to emit connectivity change event: {}",
e
);
} else {
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
log::info!(
"[ConnectivityMonitor] Emitted connectivity change: {}",
is_reachable
);
}
}
}
@@ -181,7 +187,10 @@ impl ConnectivityReporter {
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
log::error!(
"[ConnectivityMonitor] Failed to emit reconnection event: {}",
e
);
} else {
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
}
@@ -233,7 +242,14 @@ impl ConnectivityMonitor {
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
log::info!(
"[ConnectivityMonitor] New server is {}",
if is_reachable {
"REACHABLE"
} else {
"UNREACHABLE"
}
);
}
/// Get current connectivity status
@@ -298,11 +314,16 @@ impl ConnectivityMonitor {
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)");
log::info!(
"[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)"
);
// Perform an immediate check so startup reflects reality quickly.
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
log::info!(
"[ConnectivityMonitor] Initial connectivity check: {}",
if is_reachable { "ONLINE" } else { "OFFLINE" }
);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
@@ -452,7 +473,9 @@ mod tests {
let reporter = test_reporter();
// Force offline.
reporter.apply_probe_result(false, Some("down".to_string())).await;
reporter
.apply_probe_result(false, Some("down".to_string()))
.await;
assert!(!is_reachable(&reporter).await);
// A single success brings us straight back online.
@@ -490,7 +513,9 @@ mod tests {
assert!(!is_reachable(&reporter).await);
// Should not panic or change state.
reporter.report_network_failure(Some("still down".to_string())).await;
reporter
.report_network_failure(Some("still down".to_string()))
.await;
assert!(!is_reachable(&reporter).await);
}
}
+101 -47
View File
@@ -105,13 +105,21 @@ impl CredentialStore {
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
pub fn save_token(
&self,
user_id: &str,
token: &str,
) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Saving token for user {} to encrypted file at {:?}",
user_id,
self.credentials_path
);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
@@ -124,7 +132,11 @@ impl CredentialStore {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
log::debug!(
"Getting token for user {} from encrypted file at {:?}",
user_id,
self.credentials_path
);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
@@ -197,7 +209,7 @@ impl CredentialStore {
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
@@ -232,8 +244,8 @@ impl CredentialStore {
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
use std::process::{Command, Stdio};
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
@@ -248,20 +260,27 @@ impl CredentialStore {
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
stdin.write_all(token.as_bytes()).map_err(|e| {
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
})?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
let status = child.wait().map_err(|e| {
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
})?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool failed with status: {}",
status
)))
}
}
@@ -290,7 +309,11 @@ impl CredentialStore {
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
log::debug!(
"Looking up token with service={}, username={}",
SERVICE_NAME,
key
);
let output = Command::new("secret-tool")
.arg("lookup")
@@ -299,18 +322,29 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
log::debug!(
"secret-tool lookup succeeded, token length: {}",
output.stdout.len()
);
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.map_err(|e| {
CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
})?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
log::warn!(
"secret-tool lookup failed with status: {} stderr: {}",
output.status,
stderr
);
Err(CredentialError::NotFound)
}
}
@@ -348,13 +382,18 @@ impl CredentialStore {
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
})?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
Err(CredentialError::Keyring(format!(
"secret-tool clear failed with status: {}",
status
)))
}
}
@@ -398,10 +437,7 @@ impl CredentialStore {
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
@@ -410,7 +446,8 @@ impl CredentialStore {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
|| line.starts_with("ro.product.model=")
{
hasher.update(line.as_bytes());
}
}
@@ -439,8 +476,8 @@ impl CredentialStore {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
let encrypted_data = fs::read_to_string(&self.credentials_path)
.map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
@@ -456,19 +493,21 @@ impl CredentialStore {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let json =
serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
getrandom::getrandom(&mut nonce_bytes)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
@@ -488,14 +527,16 @@ impl CredentialStore {
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
return Err(CredentialError::Encryption(
"Invalid encrypted data".to_string(),
));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
@@ -686,32 +727,39 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let token_jstring = env.new_string(token).map_err(|e| {
CredentialError::Keyring(format!("Failed to create token string: {}", e))
})?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
&[
JValue::Object(&key_jstring.into()),
JValue::Object(&token_jstring.into()),
],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
Err(CredentialError::Keyring(
"saveToken returned false".to_string(),
))
}
}
@@ -725,8 +773,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -767,8 +815,8 @@ mod android_keystore {
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let instance =
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
@@ -784,19 +832,25 @@ mod android_keystore {
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
.map_err(|e| {
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
})?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
Err(CredentialError::Keyring(
"deleteToken returned false".to_string(),
))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
pub use android_keystore::{
initialize_secure_storage, test_keystore_available as android_test_keystore_available,
};
#[cfg(test)]
mod tests {
+5 -2
View File
@@ -36,7 +36,7 @@ impl Default for CacheConfig {
album_affinity_enabled: true,
album_affinity_threshold: 3,
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
wifi_only: false, // Allow preloading on any connection by default
wifi_only: false, // Allow preloading on any connection by default
}
}
}
@@ -225,7 +225,10 @@ impl SmartCache {
"DELETE FROM downloads WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(delete_query).await.map_err(|e| e.to_string())?;
db_service
.execute(delete_query)
.await
.map_err(|e| e.to_string())?;
freed += size as u64;
}
+19 -19
View File
@@ -8,16 +8,10 @@ use serde::{Deserialize, Serialize};
pub enum DownloadEvent {
/// Download has been queued
#[serde(rename_all = "camelCase")]
Queued {
download_id: i64,
item_id: String,
},
Queued { download_id: i64, item_id: String },
/// Download has started
#[serde(rename_all = "camelCase")]
Started {
download_id: i64,
item_id: String,
},
Started { download_id: i64, item_id: String },
/// Download progress update
#[serde(rename_all = "camelCase")]
Progress {
@@ -43,16 +37,10 @@ pub enum DownloadEvent {
},
/// Download paused
#[serde(rename_all = "camelCase")]
Paused {
download_id: i64,
item_id: String,
},
Paused { download_id: i64, item_id: String },
/// Download cancelled
#[serde(rename_all = "camelCase")]
Cancelled {
download_id: i64,
item_id: String,
},
Cancelled { download_id: i64, item_id: String },
}
#[cfg(test)]
@@ -98,9 +86,21 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"completed\""));
// Verify camelCase field names
assert!(json.contains("\"downloadId\":42"), "Expected downloadId (camelCase), got: {}", json);
assert!(json.contains("\"itemId\":\"song456\""), "Expected itemId (camelCase), got: {}", json);
assert!(json.contains("\"filePath\":"), "Expected filePath (camelCase), got: {}", json);
assert!(
json.contains("\"downloadId\":42"),
"Expected downloadId (camelCase), got: {}",
json
);
assert!(
json.contains("\"itemId\":\"song456\""),
"Expected itemId (camelCase), got: {}",
json
);
assert!(
json.contains("\"filePath\":"),
"Expected filePath (camelCase), got: {}",
json
);
// Verify roundtrip
let deserialized: DownloadEvent = serde_json::from_str(&json).unwrap();
+1 -1
View File
@@ -11,8 +11,8 @@ pub mod events;
pub mod worker;
use crate::utils::lock::MutexSafe;
use std::path::PathBuf;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
pub use worker::DownloadWorker;
+26 -13
View File
@@ -60,7 +60,11 @@ impl DownloadWorker {
}
/// Attempt a single download
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
async fn try_download<F>(
&self,
task: &DownloadTask,
on_progress: &F,
) -> Result<DownloadResult, DownloadError>
where
F: Fn(u64, Option<u64>) + Send + Sync,
{
@@ -74,10 +78,7 @@ impl DownloadWorker {
// Check for partial download
let temp_path = task.target_path.with_extension("part");
let existing_bytes = if temp_path.exists() {
fs::metadata(&temp_path)
.await
.map(|m| m.len())
.unwrap_or(0)
fs::metadata(&temp_path).await.map(|m| m.len()).unwrap_or(0)
} else {
0
};
@@ -105,14 +106,17 @@ impl DownloadWorker {
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(|len| if existing_bytes > 0 { len + existing_bytes } else { len });
.map(|len| {
if existing_bytes > 0 {
len + existing_bytes
} else {
len
}
});
// Open file for appending
let mut file = if existing_bytes > 0 {
fs::OpenOptions::new()
.append(true)
.open(&temp_path)
.await
fs::OpenOptions::new().append(true).open(&temp_path).await
} else {
fs::File::create(&temp_path).await
}
@@ -206,9 +210,18 @@ mod tests {
#[test]
fn test_exponential_backoff() {
assert_eq!(DownloadWorker::exponential_backoff(1), Duration::from_secs(5));
assert_eq!(DownloadWorker::exponential_backoff(2), Duration::from_secs(15));
assert_eq!(DownloadWorker::exponential_backoff(3), Duration::from_secs(45));
assert_eq!(
DownloadWorker::exponential_backoff(1),
Duration::from_secs(5)
);
assert_eq!(
DownloadWorker::exponential_backoff(2),
Duration::from_secs(15)
);
assert_eq!(
DownloadWorker::exponential_backoff(3),
Duration::from_secs(45)
);
}
#[test]
+132 -54
View File
@@ -72,7 +72,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] GET {}", endpoint);
let response = self.http_client
let response = self
.http_client
.get(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -83,13 +84,24 @@ impl JellyfinClient {
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
log::debug!(
"[JellyfinClient] Response status for {}: {}",
endpoint,
status
);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
// Get the response text first so we can log it
@@ -100,9 +112,15 @@ impl JellyfinClient {
// Log the raw response for sessions endpoint to help debug
if endpoint.contains("/Sessions") {
debug!("[JellyfinClient] Raw response for {}: {}", endpoint,
debug!(
"[JellyfinClient] Raw response for {}: {}",
endpoint,
if response_text.len() > 500 {
format!("{}... (truncated, {} bytes total)", &response_text[..500], response_text.len())
format!(
"{}... (truncated, {} bytes total)",
&response_text[..500],
response_text.len()
)
} else {
response_text.clone()
}
@@ -112,7 +130,8 @@ impl JellyfinClient {
// Parse the response text as JSON
let data = serde_json::from_str::<T>(&response_text).map_err(|e| {
log::error!("[JellyfinClient] Failed to parse response: {}", e);
log::error!("[JellyfinClient] Response was: {}",
log::error!(
"[JellyfinClient] Response was: {}",
if response_text.len() > 200 {
format!("{}...", &response_text[..200])
} else {
@@ -132,7 +151,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] POST {} to {}", endpoint, url);
let response: reqwest::Response = self.http_client
let response: reqwest::Response = self
.http_client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.get_auth_header())
@@ -145,13 +165,24 @@ impl JellyfinClient {
})?;
let status = response.status();
log::debug!("[JellyfinClient] Response status for {}: {}", endpoint, status);
log::debug!(
"[JellyfinClient] Response status for {}: {}",
endpoint,
status
);
if !status.is_success() {
let error_text: String = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text: String = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {} {}", status, endpoint);
log::error!("[JellyfinClient] Response: {}", error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::debug!("[JellyfinClient] Request successful for {}", endpoint);
@@ -193,7 +224,7 @@ impl JellyfinClient {
}
/// Report playback progress to Jellyfin
#[allow(dead_code)] // Will be used when playback_reporting is integrated
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub async fn report_playback_progress(
&self,
item_id: String,
@@ -220,9 +251,17 @@ impl JellyfinClient {
start_position_ticks: Option<i64>,
) -> Result<(), String> {
log::info!("[JellyfinClient] Playing on session: {}", session_id);
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id, item_ids.len(), start_index);
log::info!(
"[JellyfinClient] Item IDs: {:?}, Start index: {}",
item_ids,
start_index
);
debug!(
"[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
session_id,
item_ids.len(),
start_index
);
// Build URL with query parameters (Jellyfin expects PascalCase query params)
let mut url = format!(
@@ -244,10 +283,15 @@ impl JellyfinClient {
log::info!("[JellyfinClient] POST {}", url);
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
debug!(
"[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
session_id,
item_ids.len()
);
debug!("[JellyfinClient] Sending HTTP POST request...");
let response = self.http_client
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -263,10 +307,21 @@ impl JellyfinClient {
debug!("[JellyfinClient] Response status: {}", status);
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("[JellyfinClient] Request failed: {}", error_text);
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
error!(
"[JellyfinClient] API error {}: {}",
status.as_u16(),
error_text
);
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::info!("[JellyfinClient] Successfully sent play command to remote session");
@@ -280,7 +335,11 @@ impl JellyfinClient {
session_id: String,
command: &str,
) -> Result<(), String> {
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
self.post(
&format!("/Sessions/{}/Playing/{}", session_id, command),
&serde_json::json!({}),
)
.await
}
/// Seek on a remote session
@@ -298,7 +357,8 @@ impl JellyfinClient {
self.config.server_url, session_id, position_ticks
);
let response = self.http_client
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -307,11 +367,22 @@ impl JellyfinClient {
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
log::info!(
"[JellyfinClient] Seek to {} ticks on session {}",
position_ticks,
session_id
);
Ok(())
}
@@ -332,46 +403,42 @@ impl JellyfinClient {
payload["Arguments"] = args;
}
log::info!("[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
command_name, session_id, serde_json::to_string(&payload).unwrap_or_default());
log::info!(
"[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
command_name,
session_id,
serde_json::to_string(&payload).unwrap_or_default()
);
self.post(
&format!("/Sessions/{}/Command", session_id),
&payload
).await
self.post(&format!("/Sessions/{}/Command", session_id), &payload)
.await
}
/// Set volume on a remote session
pub async fn session_set_volume(
&self,
session_id: String,
volume: i32,
) -> Result<(), String> {
pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
self.send_general_command(
&session_id,
"SetVolume",
Some(serde_json::json!({ "Volume": volume.to_string() })),
).await
)
.await
}
/// Toggle mute on a remote session
pub async fn session_toggle_mute(
&self,
session_id: String,
) -> Result<(), String> {
pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
self.send_general_command(
&session_id,
"ToggleMute",
None,
).await
self.send_general_command(&session_id, "ToggleMute", None)
.await
}
/// Get all active sessions
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
info!(
"[JellyfinClient] Fetched {} sessions from API",
sessions.len()
);
for session in &sessions {
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
session.id, session.device_name, session.client, session.supports_remote_control);
@@ -382,7 +449,9 @@ impl JellyfinClient {
/// Get a specific session by ID
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
let sessions = self.get_sessions().await?;
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
Ok(sessions
.into_iter()
.find(|s| s.id.as_deref() == Some(session_id)))
}
// --- JellyLMS multi-room sync groups -----------------------------------
@@ -415,12 +484,14 @@ impl JellyfinClient {
/// Remove a single LMS player from whatever sync group it's in.
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
.await
}
/// Dissolve an entire LMS sync group, identified by its master's MAC.
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
.await
}
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
@@ -429,7 +500,8 @@ impl JellyfinClient {
log::debug!("[JellyfinClient] DELETE {}", endpoint);
let response = self.http_client
let response = self
.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.send()
@@ -438,8 +510,15 @@ impl JellyfinClient {
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!(
"Jellyfin API error {}: {}",
status.as_u16(),
error_text
));
}
Ok(())
}
@@ -570,7 +649,6 @@ pub struct PlayState {
pub shuffle_mode: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
+39 -28
View File
@@ -40,7 +40,7 @@ pub enum ErrorKind {
/// Enhanced HTTP client with retry logic and error classification
#[derive(Clone)]
pub struct HttpClient {
pub(crate) client: Client, // Make accessible within crate for custom requests
pub(crate) client: Client, // Make accessible within crate for custom requests
config: HttpConfig,
}
@@ -131,18 +131,15 @@ impl HttpClient {
/// Check if a request should be retried based on the error
pub fn should_retry(error: &reqwest::Error) -> bool {
match Self::classify_error(error) {
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Network => true, // Retry network errors
ErrorKind::Server => true, // Retry 5xx server errors
ErrorKind::Authentication => false, // Don't retry 401/403
ErrorKind::Client => false, // Don't retry other 4xx errors
ErrorKind::Client => false, // Don't retry other 4xx errors
}
}
/// Make a request with automatic retry on network errors
pub async fn request_with_retry(
&self,
request: Request,
) -> Result<Response, reqwest::Error> {
pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
let max_retries = self.config.max_retries;
let mut last_error: Option<reqwest::Error> = None;
@@ -192,44 +189,58 @@ impl HttpClient {
Err(last_error.unwrap())
}
/// Make a GET request with retry
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
let request = self.client.get(url).build()?;
self.request_with_retry(request).await
}
/// 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);
/// Make a GET request and deserialize JSON response with retry
pub async fn get_json_with_retry<T: DeserializeOwned>(
&self,
url: &str,
) -> Result<T, String> {
let response = self.get_with_retry(url).await
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
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("HTTP {}: {}", status, error_text));
}
response.json::<T>().await
response
.json::<T>()
.await
.map_err(|e| format!("Failed to parse JSON: {}", e))
}
/// Quick ping to check if a server is reachable (no retry)
pub async fn ping(&self, url: &str) -> bool {
let request = self.client.get(url)
let request = self
.client
.get(url)
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
.build();
match request {
Ok(req) => {
match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
}
}
Ok(req) => match self.client.execute(req).await {
Ok(response) => response.status().is_success(),
Err(_) => false,
},
Err(_) => false,
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub struct PlaybackStoppedRequest {
/// Request body for reporting playback progress
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)] // Will be used when playback_reporting is integrated
#[allow(dead_code)] // Will be used when playback_reporting is integrated
pub struct PlaybackProgressRequest {
pub item_id: String,
pub position_ticks: i64,
+331 -119
View File
@@ -14,120 +14,285 @@ mod storage;
mod thumbnail;
pub mod utils;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use log::{error, info};
#[cfg(target_os = "android")]
use log::warn;
use log::{error, info};
use std::sync::{Arc, Mutex};
use tauri::{Emitter, Manager};
use tauri_specta::Builder;
use tokio::sync::Mutex as TokioMutex;
use commands::{
sync_full_catalog, catalog_sync_status, resume_queued_downloads,
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,
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
get_album_affinity_status,
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
pin_item, unpin_item, is_item_pinned,
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
player_toggle_shuffle,
// Sleep timer and autoplay commands
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
player_get_autoplay_settings, player_set_autoplay_settings,
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
// HTML5 video state-report commands
player_report_state, player_report_position, player_report_media_loaded,
// Queue manipulation commands
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
player_remove_from_queue, player_move_in_queue, player_skip_to,
// Preload commands
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
// Jellyfin reporting commands
player_configure_jellyfin, player_disable_jellyfin,
// Session management commands
player_get_session, player_dismiss_session,
// Remote session control commands
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
remote_session_toggle_mute,
// LMS multi-room sync group commands
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
// Session polling commands
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
// Playback mode commands
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
playback_mode_get_remote_status,
// Playback reporting commands
playback_reporter_init, playback_reporter_destroy,
playback_report_start, playback_report_progress, playback_report_stopped,
playback_mark_played, PlaybackReporterWrapper,
// Auth commands
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
auth_stop_verification, auth_reauthenticate,
// Device commands
device_get_id, device_set_id,
// Connectivity commands
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
connectivity_start_monitoring, connectivity_stop_monitoring,
connectivity_mark_reachable, connectivity_mark_unreachable,
// Storage commands
storage_delete_server, storage_delete_user, storage_get_access_token,
storage_get_active_session, storage_get_active_user, storage_get_path,
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
storage_update_playback_context,
// Offline cache commands
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
storage_save_library, storage_save_item, storage_get_pending_sync_count,
// Sync queue commands
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
// Thumbnail cache and image commands
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
// People cache commands
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
// Series audio preferences
storage_save_series_audio_preference, storage_get_series_audio_preference,
// Repository commands
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
repository_get_rediscover_albums,
repository_get_genres, repository_search, repository_get_playback_info,
repository_get_video_stream_url, repository_get_audio_stream_url,
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
repository_get_subtitle_url, repository_get_video_download_url,
// Playlist commands
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
playlist_add_items, playlist_remove_items, playlist_move_item,
// Conversion commands
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
calc_progress, convert_percent_to_volume,
AuthManagerWrapper, SessionVerifierWrapper,
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
use auth::AuthManager;
use commands::{
auth_connect_to_server,
auth_get_session,
// Auth commands
auth_initialize,
auth_login,
auth_logout,
auth_reauthenticate,
auth_set_session,
auth_start_verification,
auth_stop_verification,
auth_verify_session,
calc_progress,
cancel_download,
catalog_sync_status,
clear_stale_downloads,
// Connectivity commands
connectivity_check_server,
connectivity_get_status,
connectivity_mark_reachable,
connectivity_mark_unreachable,
connectivity_set_server_url,
connectivity_start_monitoring,
connectivity_stop_monitoring,
convert_percent_to_volume,
convert_ticks_to_seconds,
delete_album_downloads,
delete_all_downloads,
delete_download,
// Device commands
device_get_id,
device_set_id,
download_album,
download_item,
download_item_and_start,
download_season,
download_series,
download_video,
enqueue_download,
enqueue_video_downloads,
// Conversion commands
format_time_seconds,
format_time_seconds_long,
get_album_affinity_status,
get_album_recommendations,
get_download_manager_stats,
get_download_storage_stats,
get_downloads,
get_smart_cache_config,
get_smart_cache_stats,
image_get_url,
is_item_pinned,
lms_create_sync_group,
lms_dissolve_sync_group,
// LMS multi-room sync group commands
lms_get_sync_groups,
lms_unsync_player,
mark_download_completed,
mark_download_failed,
offline_get_items,
offline_is_available,
offline_search,
pause_download,
pin_item,
playback_mark_played,
// Playback mode commands
playback_mode_get_current,
playback_mode_get_remote_status,
playback_mode_is_transferring,
playback_mode_set,
playback_mode_set_transferring,
playback_mode_transfer_to_local,
playback_mode_transfer_to_remote,
playback_report_progress,
playback_report_start,
playback_report_stopped,
playback_reporter_destroy,
// Playback reporting commands
playback_reporter_init,
// Queue manipulation commands
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_cancel_autoplay_countdown,
player_cancel_sleep_timer,
// Jellyfin reporting commands
player_configure_jellyfin,
player_cycle_repeat,
player_disable_jellyfin,
player_dismiss_session,
player_enter_background_audio,
player_exit_background_audio,
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_queue,
// Session management commands
player_get_session,
player_get_sleep_timer,
player_get_status,
player_get_video_settings,
player_move_in_queue,
player_next,
player_on_playback_ended,
player_pause,
player_play,
player_play_album_track,
player_play_item,
player_play_next_episode,
player_play_queue,
player_play_tracks,
// Preload commands
player_preload_upcoming,
player_previous,
player_remove_from_queue,
player_report_media_loaded,
player_report_position,
// HTML5 video state-report commands
player_report_state,
player_seek,
player_seek_video,
player_set_audio_settings,
player_set_audio_track,
player_set_autoplay_settings,
player_set_cache_config,
// Sleep timer and autoplay commands
player_set_sleep_timer,
player_set_subtitle_track,
player_set_video_settings,
player_set_volume,
player_skip_to,
player_stop,
player_switch_audio_track,
player_toggle,
player_toggle_mute,
player_toggle_shuffle,
playlist_add_items,
// Playlist commands
playlist_create,
playlist_delete,
playlist_get_items,
playlist_move_item,
playlist_remove_items,
playlist_rename,
// Remote session control commands
remote_play_on_session,
remote_send_command,
remote_session_seek,
remote_session_set_volume,
remote_session_toggle_mute,
// Repository commands
repository_create,
repository_destroy,
repository_get_audio_only_stream_url_for_video,
repository_get_audio_stream_url,
repository_get_channels,
repository_get_genres,
repository_get_image_url,
repository_get_item,
repository_get_items,
repository_get_items_by_person,
repository_get_latest_items,
repository_get_libraries,
repository_get_live_tv_channels,
repository_get_next_up_episodes,
repository_get_person,
repository_get_playback_info,
repository_get_recently_played_audio,
repository_get_rediscover_albums,
repository_get_resume_items,
repository_get_resume_movies,
repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
repository_get_video_stream_url,
repository_jray_actors_at,
repository_mark_favorite,
repository_open_live_stream,
repository_report_playback_progress,
repository_report_playback_start,
repository_report_playback_stopped,
repository_search,
repository_unmark_favorite,
resume_download,
resume_queued_downloads,
sessions_poll_now,
// Session polling commands
sessions_set_polling_hint,
set_max_concurrent_downloads,
set_show_server_catalog,
start_download,
// Storage commands
storage_delete_server,
storage_delete_user,
storage_get_access_token,
storage_get_active_session,
storage_get_active_user,
storage_get_item,
storage_get_item_people,
storage_get_items,
// Offline cache commands
storage_get_libraries,
storage_get_path,
storage_get_pending_sync_count,
storage_get_person,
storage_get_playback_progress,
storage_get_security_status,
storage_get_series_audio_preference,
storage_get_servers,
storage_get_size,
storage_get_users,
storage_init,
storage_mark_played,
storage_mark_synced,
storage_save_item,
storage_save_item_people,
storage_save_library,
// People cache commands
storage_save_person,
// Series audio preferences
storage_save_series_audio_preference,
storage_save_server,
storage_save_user,
storage_search_items,
storage_set_active_user,
storage_toggle_favorite,
storage_update_playback_context,
storage_update_playback_progress,
sync_cleanup_completed,
sync_clear_user,
sync_full_catalog,
sync_get_pending,
sync_get_pending_count,
sync_mark_completed,
sync_mark_failed,
sync_mark_processing,
// Sync queue commands
sync_queue_mutation,
thumbnail_clear_cache,
thumbnail_delete_item,
// Thumbnail cache and image commands
thumbnail_get_cached,
thumbnail_get_stats,
thumbnail_save,
thumbnail_set_limit,
unpin_item,
update_smart_cache_config,
AuthManagerWrapper,
ConnectivityMonitorWrapper,
CredentialStoreWrapper,
DatabaseWrapper,
DownloadManagerWrapper,
MediaSessionManagerWrapper,
PlaybackModeManagerWrapper,
PlaybackReporterWrapper,
PlayerStateWrapper,
RepositoryManagerWrapper,
SessionPollerWrapper,
SessionVerifierWrapper,
SmartCacheWrapper,
ThumbnailCacheWrapper,
VideoSettingsWrapper,
};
use connectivity::ConnectivityMonitor;
use credentials::CredentialStore;
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig};
#[cfg(target_os = "android")]
use playback_mode::PlaybackModeManager;
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
@@ -138,7 +303,7 @@ use player::NullBackend;
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
#[cfg(target_os = "android")]
use credentials::initialize_secure_storage;
@@ -147,7 +312,9 @@ use credentials::initialize_secure_storage;
use player::ExoPlayerBackend;
#[cfg(target_os = "android")]
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
use player::{
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
///
@@ -210,7 +377,11 @@ impl MediaSessionHandler {
"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,
"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;
@@ -297,7 +468,10 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
log::info!("[RemoteVolume] Spawning async task to send volume command...");
tauri::async_runtime::spawn(async move {
log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
match playback_mode.send_remote_volume_command(&command_str, volume).await {
match playback_mode
.send_remote_volume_command(&command_str, volume)
.await
{
Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
}
@@ -355,9 +529,16 @@ fn create_player_backend(
Ok(java_vm) => {
match java_vm.attach_current_thread() {
Ok(mut env) => {
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
let context_obj =
unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
match ExoPlayerBackend::new(
&mut env,
&context_obj,
_event_emitter.clone(),
playback_reporter.clone(),
position_throttler.clone(),
) {
Ok(backend) => {
info!("Successfully initialized ExoPlayer backend for Android");
return Box::new(backend);
@@ -370,13 +551,21 @@ fn create_player_backend(
}
}
Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("attach JNI thread failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
}
Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
emit_backend_init_failed(
&app_handle,
"exoplayer",
format!("create JavaVM failed: {}", e),
);
return Box::new(NullBackend::new());
}
}
@@ -440,6 +629,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
player_enter_background_audio,
player_exit_background_audio,
player_play_queue,
player_play_album_track,
player_play_tracks,
@@ -588,6 +779,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
enqueue_video_downloads,
sync_full_catalog,
catalog_sync_status,
set_show_server_catalog,
resume_queued_downloads,
get_download_manager_stats,
set_max_concurrent_downloads,
@@ -655,6 +847,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_playback_info,
repository_get_video_stream_url,
repository_get_audio_stream_url,
repository_get_audio_only_stream_url_for_video,
repository_get_live_tv_channels,
repository_get_channels,
repository_open_live_stream,
@@ -721,7 +914,12 @@ fn enable_linux_hardware_video_decoding() {
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
@@ -924,6 +1122,9 @@ pub fn run() {
player_arc.clone(),
);
let playback_mode_arc = Arc::new(playback_mode_manager);
// Broadcast mode changes so the frontend's mirror store reconciles to
// this authoritative one (prevents remote/local control desync).
playback_mode_arc.set_event_emitter(event_emitter.clone());
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
app.manage(playback_mode_wrapper);
@@ -934,9 +1135,11 @@ pub fn run() {
playback_mode_arc.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_wrapper = SessionPollerWrapper(session_poller_arc);
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
app.manage(session_poller_wrapper);
// On Android, set up the MediaSession (lockscreen) handler and the
@@ -963,6 +1166,9 @@ pub fn run() {
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
app.manage(video_settings);
// Background-audio handoff base offset (UR-040).
app.manage(commands::player::BackgroundAudioOffset::default());
// Initialize thumbnail cache
info!("[INIT] Initializing thumbnail cache...");
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
@@ -998,6 +1204,12 @@ pub fn run() {
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
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
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
@@ -1043,7 +1255,6 @@ pub fn run() {
.expect("error while running tauri application");
}
#[cfg(test)]
mod specta_bindings {
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
@@ -1051,9 +1262,10 @@ mod specta_bindings {
fn export_typescript_bindings() {
super::specta_builder()
.export(
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
specta_typescript::Typescript::default()
.bigint(specta_typescript::BigIntExportBehavior::Number),
"../src/lib/api/bindings.ts",
)
.expect("failed to export typescript bindings");
}
}
}
+272 -56
View File
@@ -9,7 +9,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration};
use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -48,6 +48,9 @@ pub struct PlaybackModeManager {
player_controller: Arc<TokioMutex<PlayerController>>,
current_mode: Arc<RwLock<PlaybackMode>>,
is_transferring: Arc<AtomicBool>,
/// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
}
impl PlaybackModeManager {
@@ -61,19 +64,76 @@ impl PlaybackModeManager {
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
}
}
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
/// during setup; safe to leave unset (tests do), in which case mode changes
/// simply aren't broadcast.
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
/// Set playback mode (internal use).
///
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
/// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the
/// emitter call.
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe();
*current = mode;
let changed = {
let mut current = self.current_mode.write_safe();
let changed = *current != mode;
*current = mode.clone();
changed
};
if !changed {
return;
}
let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None),
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
};
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
mode: mode_str,
session_id,
});
}
}
/// Start the Android playback service and hand it remote-volume control.
///
/// Must run on EVERY transition into remote mode, because it is what starts
/// the foreground service. Without a running service there is no media
/// notification (the lockscreen card is missing) AND system volume buttons
/// aren't intercepted for the remote session (remote volume control dead).
/// Both symptoms share this one cause, so this must not be skipped on any
/// remote-entry path (notably the empty-queue early return in
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
#[allow(unused_variables)]
fn enable_remote_control(&self) {
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!(
"[PlaybackMode] Failed to enable remote volume/service: {}",
e
);
// Non-fatal - continue; the next poll tick will retry metadata.
}
}
}
/// Check if currently transferring
@@ -97,8 +157,16 @@ impl PlaybackModeManager {
/// Send volume command to remote session
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
#[allow(dead_code)] // Called from Android JNI callback
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
pub async fn send_remote_volume_command(
&self,
command: &str,
volume: i32,
) -> Result<(), String> {
log::info!(
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
command,
volume
);
// Get the current session ID
let session_id = match self.get_mode() {
@@ -109,18 +177,18 @@ impl PlaybackModeManager {
}
};
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
log::info!(
"[PlaybackMode] Current mode is Remote, session_id={}",
session_id
);
// Get Jellyfin client
let client = {
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
log::info!("[PlaybackMode] Jellyfin client lock acquired");
@@ -139,7 +207,12 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] About to call client.session_set_volume...");
// Send the volume command
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
log::info!(
"[PlaybackMode] Sending {} command to session {} (volume: {})",
command,
session_id,
volume
);
let result = client.session_set_volume(session_id, volume).await;
match &result {
@@ -152,8 +225,11 @@ impl PlaybackModeManager {
/// Extract Jellyfin item IDs from queue items
/// Returns (item_ids, adjusted_current_index)
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
fn extract_jellyfin_ids(
&self,
items: &[crate::player::MediaItem],
original_index: usize,
) -> Result<(Vec<String>, usize), String> {
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
let mut jellyfin_item_count = 0;
@@ -179,7 +255,9 @@ impl PlaybackModeManager {
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
original_index
);
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
return Err(
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
);
}
};
@@ -212,7 +290,9 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
// Perform the transfer
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
let result = self
.transfer_to_remote_inner(&session_id, position_override)
.await;
// Clear transferring flag
self.is_transferring.store(false, Ordering::Relaxed);
@@ -226,7 +306,10 @@ impl PlaybackModeManager {
position_override: Option<f64>,
) -> Result<(), String> {
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
debug!(
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
session_id
);
// If we're already controlling a remote session, that *old* session — not
// the idle local player — is the source of truth for the current track and
@@ -250,13 +333,26 @@ impl PlaybackModeManager {
let original_index = queue.current_index().unwrap_or(0);
let items = queue.items();
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
log::info!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
debug!(
"[PlaybackMode] Queue has {} items, original_index={}",
items.len(),
original_index
);
// Log each item's jellyfin_id for debugging
for (i, item) in items.iter().enumerate() {
let jf_id = item.jellyfin_id().unwrap_or("NONE");
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
log::debug!(
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
i,
item.id,
jf_id
);
}
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
@@ -297,6 +393,10 @@ impl PlaybackModeManager {
self.set_mode(PlaybackMode::Remote {
session_id: session_id.to_string(),
});
// Start the service + remote-volume control here too — otherwise this
// early return leaves remote mode with no media notification and no
// volume interception (lockscreen card missing + remote volume dead).
self.enable_remote_control();
return Ok(());
}
@@ -311,14 +411,11 @@ impl PlaybackModeManager {
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
let client = {
let client_opt = self
.jellyfin_client
.lock()
.map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
let client_opt = self.jellyfin_client.lock().map_err(|e| {
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
format!("Failed to lock Jellyfin client: {}", e)
})?;
match client_opt.as_ref() {
Some(c) => {
@@ -344,7 +441,9 @@ impl PlaybackModeManager {
match client.get_session(prev_session_id).await {
Ok(Some(session)) => {
// Resume at the previous session's position.
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
if let Some(ticks) =
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
{
position_seconds = ticks as f64 / TICKS_PER_SECOND;
log::info!(
"[PlaybackMode] Using previous remote position: {:.2}s",
@@ -352,7 +451,11 @@ impl PlaybackModeManager {
);
}
// Resume on whichever track the previous session reached.
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
if let Some(now_id) = session
.now_playing_item
.as_ref()
.and_then(|i| i.id.as_deref())
{
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
log::info!(
"[PlaybackMode] Previous session is on track {} (queue index {})",
@@ -369,8 +472,13 @@ impl PlaybackModeManager {
}
}
}
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
Ok(None) => log::warn!(
"[PlaybackMode] Previous remote session not found while reading state"
),
Err(e) => log::warn!(
"[PlaybackMode] Failed to read previous remote session: {}",
e
),
}
}
@@ -379,7 +487,10 @@ impl PlaybackModeManager {
// Log queue context for debugging (context is tracked but we always send track IDs)
match &queue_context {
QueueContext::Album { album_id, album_name } => {
QueueContext::Album {
album_id,
album_name,
} => {
log::info!(
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
album_name,
@@ -387,7 +498,10 @@ impl PlaybackModeManager {
queue_ids.len()
);
}
QueueContext::Playlist { playlist_id, playlist_name } => {
QueueContext::Playlist {
playlist_id,
playlist_name,
} => {
log::info!(
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
playlist_name,
@@ -479,7 +593,11 @@ impl PlaybackModeManager {
return Err("Remote session not found".to_string());
}
Err(e) => {
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
log::warn!(
"[PlaybackMode] Error polling session (attempt {}): {}",
attempts,
e
);
// Continue polling - transient errors are OK
}
}
@@ -511,9 +629,15 @@ impl PlaybackModeManager {
// up with two devices playing at once. Do this only after the new session
// is confirmed playing, so a failure here doesn't leave us with silence.
if let Some(prev_session_id) = previous_remote_session {
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
log::info!(
"[PlaybackMode] Stopping previous remote session {}",
prev_session_id
);
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
log::warn!(
"[PlaybackMode] Failed to stop previous remote session: {}",
e
);
}
}
@@ -533,7 +657,9 @@ impl PlaybackModeManager {
);
}
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
player
.stop()
.map_err(|e| format!("Failed to stop playback: {}", e))?;
// Log queue state AFTER stop (should be unchanged)
{
@@ -552,14 +678,9 @@ impl PlaybackModeManager {
session_id: session_id.to_string(),
});
// Enable remote volume control on Android (intercepts volume buttons)
#[cfg(target_os = "android")]
{
if let Err(e) = crate::player::enable_remote_volume(50) {
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
// Non-fatal - continue with transfer
}
}
// Start the service + remote-volume control (intercepts volume buttons,
// and starts the foreground service that renders the lockscreen card).
self.enable_remote_control();
log::info!("[PlaybackMode] Successfully transferred to remote");
Ok(())
@@ -621,11 +742,20 @@ impl PlaybackModeManager {
};
// Stop remote playback
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
match client.send_session_command(session_id.clone(), "Stop").await {
log::info!(
"[PlaybackMode] Stopping remote playback on session: {}",
session_id
);
match client
.send_session_command(session_id.clone(), "Stop")
.await
{
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
Err(e) => {
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
log::warn!(
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
e
);
// Don't fail the transfer if we can't stop the remote session
// The user is already playing locally, so this is not critical
}
@@ -702,6 +832,84 @@ mod tests {
);
}
/// Capturing emitter so we can assert what `set_mode` broadcasts.
struct CapturingEmitter {
events: Mutex<Vec<PlayerStatusEvent>>,
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
let emitter = Arc::new(CapturingEmitter {
events: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
manager.set_event_emitter(emitter.clone());
(manager, emitter)
}
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
/// the frontend can reconcile its mirror store to this authoritative one.
#[test]
fn test_set_mode_emits_change_event() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3, "one event per real mode change");
match &events[0] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "remote");
assert_eq!(session_id.as_deref(), Some("sess-1"));
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[1] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "local");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[2] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "idle");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
}
/// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions.
#[test]
fn test_set_mode_deduplicates_no_op() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
assert_eq!(
emitter.events.lock().unwrap().len(),
1,
"repeated identical mode set emits only once"
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).
@@ -709,7 +917,10 @@ mod tests {
fn test_start_position_ticks_from_seconds() {
// Mid-track positions convert to ticks (10M ticks per second).
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
assert_eq!(
start_position_ticks_from_seconds(123.45),
Some(1_234_500_000)
);
// At/near the start, send no resume position so the track casts from 0.
assert_eq!(start_position_ticks_from_seconds(0.0), None);
@@ -797,7 +1008,12 @@ mod tests {
fn test_extract_all_jellyfin_ids_from_album() {
// Simulate an album with 5 tracks - all should be extracted
let items: Vec<MediaItem> = (1..=5)
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
.map(|i| {
create_test_item_with_jellyfin_id(
&format!("track_{}", i),
&format!("jf_track_{}", i),
)
})
.collect();
let manager = super::PlaybackModeManager::new(
@@ -831,7 +1047,7 @@ mod tests {
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
create_test_item_with_jellyfin_id("4", "jf_4"),
];
@@ -866,7 +1082,7 @@ mod tests {
// Current item has no Jellyfin ID - should fail
let items = vec![
create_test_item_with_jellyfin_id("1", "jf_1"),
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_local("2"), // Local, no Jellyfin ID
create_test_item_with_jellyfin_id("3", "jf_3"),
];
+5 -5
View File
@@ -1,9 +1,9 @@
pub mod reporter;
pub mod throttle;
pub mod sync_processor;
pub mod throttle;
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
#[allow(unused_imports)] // Will be used when sync processor is integrated
pub use sync_processor::SyncProcessor;
#[allow(unused_imports)] // Will be used when position updates are hooked
pub use throttle::EventThrottler;
+122 -47
View File
@@ -14,7 +14,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
/// Playback context information
#[derive(Debug, Clone)]
pub struct PlaybackContext {
pub context_type: String, // "container" or "single"
pub context_type: String, // "container" or "single"
pub context_id: Option<String>,
}
@@ -65,7 +65,11 @@ impl PlaybackReporter {
///
/// Always updates local DB first, then attempts server sync if online.
/// If server sync fails, operation is queued for retry.
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
pub async fn report(
&self,
operation: PlaybackOperation,
is_online: bool,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
// Always update local DB first (works offline)
@@ -93,7 +97,11 @@ impl PlaybackReporter {
/// Updates local database with playback info
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
playback_context_type, playback_context_id, pending_sync)
@@ -113,12 +121,22 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused: _,
}
| PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let query = Query::with_params(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
@@ -133,8 +151,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for progress/stop: {}",
item_id
);
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -152,8 +176,14 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!(
"[PlaybackReporter] Updated local DB for mark played: {}",
item_id
);
}
}
@@ -163,34 +193,57 @@ impl PlaybackReporter {
/// Syncs to Jellyfin server
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
let client_guard = self.jellyfin_client.lock().await;
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
let client = client_guard
.as_ref()
.ok_or("JellyfinClient not initialized")?;
match operation {
PlaybackOperation::Start { item_id, position_ticks, .. } => {
client.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Start {
item_id,
position_ticks,
..
} => {
client
.report_playback_start(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
}
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
client.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
).await?;
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
} => {
client
.report_playback_progress(
item_id.clone(),
*position_ticks,
*is_paused,
None, // play_session_id
)
.await?;
log::debug!(
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
item_id,
is_paused
);
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
client.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
).await?;
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
client
.report_playback_stopped(
item_id.clone(),
*position_ticks,
None, // play_session_id
)
.await?;
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
}
@@ -199,12 +252,13 @@ impl PlaybackReporter {
// For now, report as stopped at max position
// TODO: Fetch item runtime from DB or assume 100% completion
let max_ticks = i64::MAX; // Temporary - should be actual runtime
client.report_playback_stopped(
item_id.clone(),
max_ticks,
None,
).await?;
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
client
.report_playback_stopped(item_id.clone(), max_ticks, None)
.await?;
log::info!(
"[PlaybackReporter] Reported mark played to server: {}",
item_id
);
}
}
@@ -214,13 +268,21 @@ impl PlaybackReporter {
/// Queues operation for later sync
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
let (op_name, item_id, payload) = match operation {
PlaybackOperation::Start { item_id, position_ticks, context } => {
PlaybackOperation::Start {
item_id,
position_ticks,
context,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
"context_type": context.as_ref().map(|c| &c.context_type),
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
});
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_start",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::Progress { .. } => {
@@ -230,11 +292,18 @@ impl PlaybackReporter {
return Ok(());
}
PlaybackOperation::Stopped { item_id, position_ticks } => {
PlaybackOperation::Stopped {
item_id,
position_ticks,
} => {
let payload_data = serde_json::json!({
"position_ticks": position_ticks,
});
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
(
"report_playback_stopped",
Some(item_id.clone()),
Some(payload_data.to_string()),
)
}
PlaybackOperation::MarkPlayed { item_id } => {
@@ -253,7 +322,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
Ok(())
@@ -269,7 +341,10 @@ impl PlaybackReporter {
],
);
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
self.db_service
.execute(query)
.await
.map_err(|e| e.to_string())?;
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
Ok(())
@@ -278,10 +353,10 @@ impl PlaybackReporter {
/// Extracts item_id from operation
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
match operation {
PlaybackOperation::Start { item_id, .. } |
PlaybackOperation::Progress { item_id, .. } |
PlaybackOperation::Stopped { item_id, .. } |
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
PlaybackOperation::Start { item_id, .. }
| PlaybackOperation::Progress { item_id, .. }
| PlaybackOperation::Stopped { item_id, .. }
| PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
}
}
}
@@ -6,8 +6,8 @@
#![allow(dead_code)]
#![allow(unused_imports)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
@@ -17,9 +17,9 @@ use crate::storage::db_service::RusqliteService;
/// Configuration for sync processor
pub struct SyncConfig {
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
pub max_retries: u32, // 5
pub base_retry_delay_ms: u64, // 1000ms
pub batch_size: usize, // 10 items
}
impl Default for SyncConfig {
+186 -84
View File
@@ -4,9 +4,9 @@
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jdouble, jfloat, jint};
@@ -17,7 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
/// Global handler for media session commands from Android lockscreen/notification
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> = OnceLock::new();
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
OnceLock::new();
/// Global handler for remote volume changes from Android volume buttons
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> = OnceLock::new();
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
OnceLock::new();
/// Global player controller for autoplay decisions
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
@@ -87,9 +89,9 @@ impl DetectedCodecs {
/// Public function to get detected codecs (for use in repository layer)
pub fn get_detected_codecs() -> Option<(String, String)> {
DETECTED_CODECS.get().map(|codecs| {
(codecs.video_codecs_string(), codecs.audio_codecs_string())
})
DETECTED_CODECS
.get()
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
}
/// Trait for handling media commands from Android MediaSession.
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
let _ = JAVA_VM.set(vm);
// Store the Context as a global reference for later use
let context_global = env
.new_global_ref(context)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global context ref: {}", e)))?;
let context_global = env.new_global_ref(context).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
})?;
let _ = APP_CONTEXT.set(context_global);
// Store the event emitter
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
})?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| PlayerError::playback_failed(format!("Failed to create class name string: {}", e)))?;
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
})?;
let player_class_obj = env
.call_method(
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&player_class_name.into())],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to Class: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
})?;
// Cast to JClass for static method calls
let player_class = JClass::from(player_class_obj);
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
})?;
// Get the singleton instance
let player_obj = env
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
&[],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
.map_err(|e| {
PlayerError::playback_failed(format!(
"Failed to get JellyTauPlayer instance: {}",
e
))
})?
.l()
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to object: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
})?;
// Create a global reference to keep the player alive
let player_ref = env
.new_global_ref(player_obj)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global ref: {}", e)))?;
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
})?;
Ok(Self {
player_ref,
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
/// Call a void method on the player with no arguments
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
env.call_method(&self.player_ref, method, "()V", &[])
.map_err(|e| PlayerError::playback_failed(format!("Failed to call {}: {}", method, e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
})?;
Ok(())
}
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
state.is_loaded = false;
}
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
// Create JNI strings for required parameters
let url_jstring = env
.new_string(&url)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create URL string: {}", e)))?;
let url_jstring = env.new_string(&url).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
})?;
let media_id_jstring = env
.new_string(&media_id)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media ID string: {}", e)))?;
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
})?;
let title_jstring = env
.new_string(&title)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create title string: {}", e)))?;
let title_jstring = env.new_string(&title).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
})?;
// Create JNI strings for optional parameters (null if None)
let artist_jstring = match &artist {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artist string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
})?),
None => None,
};
let album_jstring = match &album {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create album string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
})?),
None => None,
};
let artwork_jstring = match &artwork_url {
Some(a) => Some(env.new_string(a)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artwork string: {}", e)))?),
Some(a) => Some(env.new_string(a).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
})?),
None => None,
};
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
MediaType::Video => "video",
MediaType::Audio => "audio",
};
let media_type_jstring = env
.new_string(media_type_str)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media type string: {}", e)))?;
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
})?;
// Serialize subtitles to JSON for passing to Kotlin
let subtitles_json = serde_json::to_string(&media.subtitles)
.unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env
.new_string(&subtitles_json)
.map_err(|e| PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e)))?;
let subtitles_json =
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
})?;
// Call loadWithMetadata for MediaSession support (lockscreen controls)
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
env.exception_describe().ok();
env.exception_clear().ok();
}
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
return Err(PlayerError::playback_failed(format!(
"Failed to call loadWithMetadata: {}",
e
)));
}
debug!("[Android] Successfully called loadWithMetadata");
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
let clamped = volume.clamp(0.0, 1.0);
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(stream_index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
})?;
Ok(())
}
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let vm = JAVA_VM.get().ok_or_else(|| {
PlayerError::playback_failed("JavaVM not initialized")
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
"(I)V",
&[JValue::Int(index)],
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
})?;
Ok(())
}
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
if !state.state.is_playing() {
return;
}
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
match state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
{
Some(id) => id,
None => return,
}
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString,
media_id: JString,
) {
let state_str: String = env
.get_string(&state)
.map(|s| s.into())
.unwrap_or_default();
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlayer class using the app's class loader
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
let player_class_name = env
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let player_class_obj = env
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
}
// Call startPlaybackService() on the player instance
env.call_method(
&player_obj,
"startPlaybackService",
"()V",
&[],
)
.map_err(|e| format!("Failed to start playback service: {}", e))?;
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
.map_err(|e| format!("Failed to start playback service: {}", e))?;
log::info!("[Android] JellyTauPlaybackService start requested");
Ok(())
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
/// @param initial_volume Initial volume level (0-100)
#[cfg(target_os = "android")]
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
log::info!("[Android] Enabling remote volume control (volume={})", initial_volume);
log::info!(
"[Android] Enabling remote volume control (volume={})",
initial_volume
);
// Ensure the playback service is started first
start_playback_service()?;
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
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
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the JellyTauPlaybackService class using the app's class loader
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
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
@@ -1273,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
Ok(())
}
/// Set the base position offset (seconds) on the lockscreen MediaSession.
///
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
/// service isn't running yet, so it's safe to call unconditionally.
pub fn set_position_offset(offset_seconds: f64) -> 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 - nothing to offset.
if service_obj.is_null() {
return Ok(());
}
env.call_method(
&service_obj,
"setPositionOffset",
"(D)V",
&[JValue::Double(offset_seconds)],
)
.map_err(|e| format!("Failed to set position offset: {}", e))?;
Ok(())
}
/// Stub implementations for non-Android platforms
#[cfg(not(target_os = "android"))]
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
+1 -1
View File
@@ -1,7 +1,7 @@
// Autoplay decision logic
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
use serde::{Deserialize, Serialize};
use crate::repository::types::MediaItem;
use serde::{Deserialize, Serialize};
/// Autoplay decision result - determines what happens after playback ends
#[derive(specta::Type, Debug, Clone, Serialize)]
+12 -2
View File
@@ -163,7 +163,12 @@ impl PlayerBackend for NullBackend {
}
fn play(&mut self) -> Result<(), PlayerError> {
if let PlayerState::Paused { media, position, duration } = &self.state {
if let PlayerState::Paused {
media,
position,
duration,
} = &self.state
{
self.state = PlayerState::Playing {
media: media.clone(),
position: *position,
@@ -174,7 +179,12 @@ impl PlayerBackend for NullBackend {
}
fn pause(&mut self) -> Result<(), PlayerError> {
if let PlayerState::Playing { media, position, duration } = &self.state {
if let PlayerState::Playing {
media,
position,
duration,
} = &self.state
{
self.state = PlayerState::Paused {
media: media.clone(),
position: *position,
+15
View File
@@ -124,6 +124,21 @@ pub enum PlayerStatusEvent {
/// All active controllable sessions from Jellyfin
sessions: Vec<crate::jellyfin::client::SessionInfo>,
},
/// The authoritative playback mode changed in the Rust backend.
///
/// The Rust `PlaybackModeManager` is the single source of truth for which
/// device playback commands route to (local vs a remote session). The
/// frontend keeps a mirror store for the UI; without this event that mirror
/// drifts out of sync (e.g. a mode transition happens inside a transfer or a
/// local stop that the frontend never learns about), and controls then route
/// to the wrong device — the classic "it keeps playing on the remote" bug.
/// The frontend reconciles its store to this payload whenever it fires.
PlaybackModeChanged {
/// New mode: "local", "remote", or "idle".
mode: String,
/// Session id when `mode == "remote"`, otherwise `None`.
session_id: Option<String>,
},
/// The user asked to disconnect from the remote session and resume locally.
///
/// Emitted when the lockscreen Stop button is pressed while casting. The
+7 -5
View File
@@ -138,8 +138,12 @@ impl MediaItem {
/// Get the Jellyfin item ID if available
pub fn jellyfin_id(&self) -> Option<&str> {
match &self.source {
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id),
MediaSource::Local { jellyfin_item_id, .. } => jellyfin_item_id.as_deref(),
MediaSource::Remote {
jellyfin_item_id, ..
} => Some(jellyfin_item_id),
MediaSource::Local {
jellyfin_item_id, ..
} => jellyfin_item_id.as_deref(),
MediaSource::DirectUrl { .. } => None,
}
}
@@ -151,9 +155,7 @@ impl MediaItem {
pub fn playback_url(&self) -> String {
match &self.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
MediaSource::Local { file_path, .. } => {
file_path.to_string_lossy().to_string()
}
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
MediaSource::DirectUrl { url } => url.clone(),
}
}
+413 -90
View File
@@ -42,8 +42,8 @@ pub use mpv_backend::MpvBackend;
#[cfg(target_os = "android")]
pub use android::{
MediaCommandHandler, RemoteVolumeHandler, enable_remote_volume, disable_remote_volume,
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
};
/// Metadata for the lockscreen / media notification.
@@ -53,6 +53,9 @@ pub use android::{
/// 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)]
// Fields are read only by the Android MediaSession bridge; on other platforms
// `update_lockscreen_metadata` is a no-op, so they're constructed but unread.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub struct LockscreenMetadata {
pub title: String,
pub artist: String,
@@ -77,6 +80,22 @@ pub fn update_lockscreen_metadata(_meta: &LockscreenMetadata) -> Result<(), Stri
}
}
/// Set the base offset (seconds) added to positions reported to the Android
/// lockscreen scrubber. Used by the background-audio handoff: the audio stream
/// starts at the handoff point (StartTimeTicks), so ExoPlayer's position is
/// relative and must be shifted back to absolute to match the full duration.
/// Pass 0.0 to clear on exit. No-op off Android.
pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return android::set_position_offset(_offset_seconds);
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};
@@ -84,9 +103,11 @@ use std::time::Duration;
use tokio::sync::Mutex as TokioMutex;
use crate::jellyfin::JellyfinClient;
use crate::settings::AudioSettings;
use crate::playback_reporting::{
EventThrottler, PlaybackContext, PlaybackOperation, PlaybackReporter,
};
use crate::repository::MediaRepository;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation, PlaybackContext};
use crate::settings::AudioSettings;
/// Central player controller that coordinates playback
pub struct PlayerController {
@@ -157,7 +178,10 @@ impl PlayerController {
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock_safe();
*jellyfin = client;
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
log::info!(
"[PlayerController] Jellyfin client configured: {}",
jellyfin.is_some()
);
}
/// Get a reference to the Jellyfin client (for remote session control)
@@ -180,7 +204,10 @@ impl PlayerController {
pub async fn set_playback_reporter(&self, reporter: Option<PlaybackReporter>) {
let mut reporter_guard = self.playback_reporter.lock().await;
*reporter_guard = reporter;
log::info!("[PlayerController] Playback reporter configured: {}", reporter_guard.is_some());
log::info!(
"[PlayerController] Playback reporter configured: {}",
reporter_guard.is_some()
);
}
/// Get a reference to the playback reporter (for backend position updates)
@@ -219,7 +246,10 @@ impl PlayerController {
let mut count = self.autoplay_episode_count.lock_safe();
*count += 1;
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
debug!(
"[PlayerController] Autoplay episode count: {}/{}",
*count, max
);
*count >= max
}
@@ -228,7 +258,10 @@ impl PlayerController {
fn reset_autoplay_count(&self) {
let mut count = self.autoplay_episode_count.lock_safe();
if *count > 0 {
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
debug!(
"[PlayerController] Resetting autoplay episode counter (was {})",
*count
);
}
*count = 0;
}
@@ -259,7 +292,10 @@ impl PlayerController {
/// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
@@ -446,7 +482,9 @@ impl PlayerController {
// Get current playback info before stopping
let jellyfin_id = {
let queue = self.queue.lock_safe();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
queue
.current()
.and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
};
let position_ticks = {
@@ -522,7 +560,10 @@ impl PlayerController {
queue.next().cloned()
};
debug!("[PlayerController] next: {:?}", next_item.as_ref().map(|i| &i.title));
debug!(
"[PlayerController] next: {:?}",
next_item.as_ref().map(|i| &i.title)
);
if let Some(item) = next_item {
self.load_and_play(&item)
@@ -554,7 +595,10 @@ impl PlayerController {
queue.previous().cloned()
};
debug!("[PlayerController] previous: {:?}", prev_item.as_ref().map(|i| &i.title));
debug!(
"[PlayerController] previous: {:?}",
prev_item.as_ref().map(|i| &i.title)
);
if let Some(item) = prev_item {
self.load_and_play(&item)
@@ -707,7 +751,9 @@ impl PlayerController {
timer.update_remaining_seconds();
// Time-based timer expired: stop playback
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
if matches!(timer.mode, SleepTimerMode::Time { .. })
&& timer.remaining_seconds == 0
{
debug!("[SleepTimer] Time-based timer expired, stopping playback");
timer.cancel();
@@ -843,7 +889,10 @@ impl PlayerController {
// Check why playback ended
let end_reason = self.take_end_reason();
debug!("[PlayerController] on_playback_ended: end_reason={:?}", end_reason);
debug!(
"[PlayerController] on_playback_ended: end_reason={:?}",
end_reason
);
// Only proceed with autoplay logic if track finished naturally
match end_reason {
@@ -907,8 +956,8 @@ impl PlayerController {
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode = current.media_type == MediaType::Video
&& self.is_episode_item(&current).await;
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -936,7 +985,10 @@ impl PlayerController {
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);
warn!(
"[PlayerController] Next-episode lookup failed for {}: {}",
jellyfin_id, e
);
None
}
}
@@ -950,11 +1002,14 @@ impl PlayerController {
// Check if auto-play episode limit is reached
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
debug!(
"[PlayerController] Auto-play episode limit reached ({} episodes)",
settings.max_episodes
);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode: next_ep.0, // Repository MediaItem
current_episode: next_ep.0, // Repository MediaItem
next_episode: next_ep.1,
countdown_seconds: settings.countdown_seconds,
auto_advance: settings.enabled && !limit_reached,
@@ -993,10 +1048,16 @@ impl PlayerController {
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
let stale_reason = self.take_end_reason();
if stale_reason.is_some() {
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
debug!(
"[PlayerController] Cleared stale end_reason for video: {:?}",
stale_reason
);
}
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
log::info!(
"[PlayerController] on_video_playback_ended: item_id={}",
item_id
);
// Check sleep timer state
let timer_mode = {
@@ -1035,7 +1096,10 @@ impl PlayerController {
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);
warn!(
"[PlayerController] Next-episode lookup failed for {}: {}",
item_id, e
);
None
}
};
@@ -1044,7 +1108,10 @@ impl PlayerController {
let limit_reached = self.increment_autoplay_count();
if limit_reached {
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
debug!(
"[PlayerController] Auto-play episode limit reached ({} episodes)",
settings.max_episodes
);
}
return Ok(AutoplayDecision::ShowNextEpisodePopup {
@@ -1077,11 +1144,18 @@ impl PlayerController {
&self,
item_id: &str,
repo: &Arc<dyn crate::repository::MediaRepository>,
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
) -> Result<
Option<(
crate::repository::types::MediaItem,
crate::repository::types::MediaItem,
)>,
String,
> {
use crate::repository::types::GetItemsOptions;
// Get the current item details from repository
let current_repo_item = repo.get_item(item_id)
let current_repo_item = repo
.get_item(item_id)
.await
.map_err(|e| format!("Failed to get current item: {}", e))?;
@@ -1089,7 +1163,9 @@ impl PlayerController {
let season_id = match &current_repo_item.season_id {
Some(sid) => sid.clone(),
None => {
log::info!("[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);
}
};
@@ -1103,7 +1179,8 @@ impl PlayerController {
..Default::default()
};
let result = repo.get_items(&season_id, Some(options))
let result = repo
.get_items(&season_id, Some(options))
.await
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
@@ -1111,26 +1188,45 @@ impl PlayerController {
// (offline repo ignores sort_by and sorts by sort_name instead)
let mut episodes = result.items;
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
log::info!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
log::info!(
"[PlayerController] Season has {} episodes, looking for next after {}",
episodes.len(),
current_repo_item.id
);
// Find the current episode by ID and return the next one
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
if current_idx + 1 < episodes.len() {
let next = &episodes[current_idx + 1];
log::info!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
log::info!(
"[PlayerController] Found next episode: {} (index {})",
next.name,
current_idx + 1
);
return Ok(Some((current_repo_item, next.clone())));
} else {
log::info!("[PlayerController] Current episode is the last in the season");
}
} else {
log::info!("[PlayerController] Current episode not found in season episodes (ids: {:?})", episodes.iter().map(|e| e.id.as_str()).take(20).collect::<Vec<_>>());
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)
}
/// Start autoplay countdown thread
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
pub fn start_autoplay_countdown(
&self,
_next_item: crate::repository::types::MediaItem,
countdown_seconds: u32,
) {
// Create cancellation flag
let cancel_flag = Arc::new(Mutex::new(false));
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
@@ -1169,7 +1265,11 @@ impl Default for PlayerController {
fn default() -> Self {
let playback_reporter = Arc::new(TokioMutex::new(None));
let position_throttler = Arc::new(EventThrottler::new());
Self::new(Box::new(NullBackend::new()), playback_reporter, position_throttler)
Self::new(
Box::new(NullBackend::new()),
playback_reporter,
position_throttler,
)
}
}
@@ -1332,8 +1432,16 @@ mod tests {
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
assert_eq!(
queue_lock.current_index(),
Some(0),
"Should start at index 0"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_0",
"Current item should be item_0"
);
}
// Skip to next track
@@ -1343,15 +1451,35 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after skip"
);
assert_eq!(
queue_lock.current_index(),
Some(1),
"Index should advance to 1"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_1",
"Current item should be item_1"
);
// Verify all original items are still present
let current_items = queue_lock.items();
for (i, original) in items_clone.iter().enumerate() {
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
assert_eq!(current_items[i].title, original.title, "Item {} title should be unchanged", i);
assert_eq!(
current_items[i].id, original.id,
"Item {} should still be in queue",
i
);
assert_eq!(
current_items[i].title, original.title,
"Item {} title should be unchanged",
i
);
}
}
@@ -1362,9 +1490,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after second skip"
);
assert_eq!(
queue_lock.current_index(),
Some(2),
"Index should advance to 2"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_2",
"Current item should be item_2"
);
}
// Skip multiple times to reach the end
@@ -1375,9 +1515,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items at end"
);
assert_eq!(
queue_lock.current_index(),
Some(4),
"Index should be at last item (4)"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_4",
"Current item should be item_4"
);
}
}
@@ -1397,7 +1549,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
assert_eq!(
queue_lock.current_index(),
Some(2),
"Should be at last item"
);
}
// Try to skip past the end (without repeat mode)
@@ -1408,7 +1564,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
assert_eq!(
queue_lock.items().len(),
3,
"Queue should still have 3 items after skip at end"
);
// When we skip past the end, the queue index should stay at the last item
// or become None (depending on implementation)
// The key is the queue items themselves should be preserved
@@ -1437,9 +1597,21 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
assert_eq!(
queue_lock.items().len(),
3,
"Queue should still have 3 items"
);
assert_eq!(
queue_lock.current_index(),
Some(0),
"Should wrap to index 0"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_0",
"Should be back at item_0"
);
}
}
@@ -1456,7 +1628,11 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
assert_eq!(
queue_lock.current_index(),
Some(3),
"Should start at index 3"
);
}
// Go to previous track
@@ -1466,14 +1642,30 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
assert_eq!(
queue_lock.items().len(),
5,
"Queue should still have 5 items after previous"
);
assert_eq!(
queue_lock.current_index(),
Some(2),
"Index should move to 2"
);
assert_eq!(
queue_lock.current().unwrap().id,
"item_2",
"Current item should be item_2"
);
// Verify all original items are still present
let current_items = queue_lock.items();
for (i, original) in items_clone.iter().enumerate() {
assert_eq!(current_items[i].id, original.id, "Item {} should still be in queue", i);
assert_eq!(
current_items[i].id, original.id,
"Item {} should still be in queue",
i
);
}
}
}
@@ -1491,15 +1683,27 @@ mod tests {
// Seek to 30 seconds
controller.seek(30.0).unwrap();
assert_eq!(controller.position(), 30.0, "Position should be 30 after seeking");
assert_eq!(
controller.position(),
30.0,
"Position should be 30 after seeking"
);
// Seek to 60 seconds
controller.seek(60.0).unwrap();
assert_eq!(controller.position(), 60.0, "Position should be 60 after seeking");
assert_eq!(
controller.position(),
60.0,
"Position should be 60 after seeking"
);
// Seek backward to 15 seconds
controller.seek(15.0).unwrap();
assert_eq!(controller.position(), 15.0, "Position should be 15 after seeking backward");
assert_eq!(
controller.position(),
15.0,
"Position should be 15 after seeking backward"
);
}
#[test]
@@ -1518,10 +1722,17 @@ mod tests {
// Seek while paused
controller.seek(45.0).unwrap();
assert_eq!(controller.position(), 45.0, "Position should update while paused");
assert_eq!(
controller.position(),
45.0,
"Position should update while paused"
);
// Verify still paused after seeking
assert!(controller.state().is_paused(), "Should still be paused after seeking");
assert!(
controller.state().is_paused(),
"Should still be paused after seeking"
);
}
#[test]
@@ -1540,10 +1751,17 @@ mod tests {
// Seek while playing
controller.seek(20.0).unwrap();
assert_eq!(controller.position(), 20.0, "Position should update while playing");
assert_eq!(
controller.position(),
20.0,
"Position should update while playing"
);
// Verify still playing after seeking
assert!(controller.state().is_playing(), "Should still be playing after seeking");
assert!(
controller.state().is_playing(),
"Should still be playing after seeking"
);
}
#[test]
@@ -1558,7 +1776,12 @@ mod tests {
for pos in positions {
controller.seek(pos).unwrap();
assert_eq!(controller.position(), pos, "Position should match after seeking to {}", pos);
assert_eq!(
controller.position(),
pos,
"Position should match after seeking to {}",
pos
);
}
}
@@ -1575,9 +1798,17 @@ mod tests {
{
let queue = controller.queue();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
assert_eq!(
queue_lock.current_index(),
Some(1),
"Should start at index 1"
);
}
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
assert_eq!(
controller.position(),
42.5,
"Should resume at the requested position"
);
}
/// A None / near-zero start position starts the track from the beginning.
@@ -1613,7 +1844,11 @@ mod tests {
// Seek back to zero
controller.seek(0.0).unwrap();
assert_eq!(controller.position(), 0.0, "Should be able to seek to position 0");
assert_eq!(
controller.position(),
0.0,
"Should be able to seek to position 0"
);
}
// Autoplay decision tests
@@ -1990,7 +2225,10 @@ mod tests {
total_record_count: self.episodes.len(),
})
}
async fn get_item(&self, item_id: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
async fn get_item(
&self,
item_id: &str,
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
self.episodes
.iter()
.find(|e| e.id == item_id)
@@ -1999,55 +2237,109 @@ mod tests {
message: format!("{} not found", item_id),
})
}
async fn get_latest_items(&self, _: &str, _: Option<usize>) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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> {
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 {
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 {
@@ -2062,16 +2354,31 @@ mod tests {
async fn unmark_favorite(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(&self, _: &str) -> Result<repo_types::MediaItem, repo_types::RepoError> {
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> {
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> {
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> {
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> {
@@ -2080,16 +2387,32 @@ mod tests {
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> {
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> {
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> {
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> {
async fn move_playlist_item(
&self,
_: &str,
_: &str,
_: u32,
) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
}
+42 -20
View File
@@ -1,16 +1,16 @@
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
use super::media::{MediaItem, MediaSource};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::AudioSettings;
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::Mutex as TokioMutex;
@@ -104,11 +104,17 @@ impl MpvBackend {
// Detect and configure audio output
let audio_driver = detect_audio_system();
info!("[MpvBackend] Configuring audio output driver: {}", audio_driver);
info!(
"[MpvBackend] Configuring audio output driver: {}",
audio_driver
);
mpv.set_property("ao", audio_driver.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set audio output to '{}': {:?}. Make sure audio system is working.", audio_driver, e),
message: format!(
"Failed to set audio output to '{}': {:?}. Make sure audio system is working.",
audio_driver, e
),
})?;
// Enable verbose logging for audio initialization
@@ -123,10 +129,9 @@ impl MpvBackend {
message: format!("Failed to configure MPV audio-display: {:?}", e),
})?;
mpv.set_property("video", "no")
.map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -191,7 +196,11 @@ impl MpvBackend {
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
@@ -203,11 +212,16 @@ impl MpvBackend {
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
// Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
let media_id = state
.lock_safe()
.current_media
.as_ref()
.map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
state: if is_paused { "paused" } else { "playing" }.to_string(),
state: if is_paused { "paused" } else { "playing" }
.to_string(),
media_id,
});
}
@@ -303,14 +317,18 @@ impl MpvBackend {
}
// Check if we're playing for progress reporting
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
let is_paused = mpv_for_position
.get_property::<bool>("pause")
.unwrap_or(true);
// Only report progress to server when playing (not paused)
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock_safe();
state.current_media.as_ref()
state
.current_media
.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
@@ -333,8 +351,14 @@ impl MpvBackend {
};
match reporter_instance.report(operation, true).await {
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
Ok(_) => debug!(
"[MpvBackend] Reported progress for {}",
item_id_clone
),
Err(e) => warn!(
"[MpvBackend] Failed to report progress: {}",
e
),
}
}
});
@@ -468,9 +492,7 @@ impl PlayerBackend for MpvBackend {
}
fn position(&self) -> f64 {
self.mpv
.get_property::<f64>("time-pos")
.unwrap_or(0.0)
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
}
fn duration(&self) -> Option<f64> {
+9 -2
View File
@@ -86,7 +86,10 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(100));
let count = *counter.lock().unwrap();
assert_eq!(count, 1, "Fallback pattern should execute async code successfully");
assert_eq!(
count, 1,
"Fallback pattern should execute async code successfully"
);
}
/// Test that position update logic works in a thread
@@ -113,7 +116,11 @@ mod tests {
handle.join().unwrap();
let recorded_positions = positions.lock().unwrap();
assert_eq!(recorded_positions.len(), 5, "Should have recorded 5 position updates");
assert_eq!(
recorded_positions.len(),
5,
"Should have recorded 5 position updates"
);
// Verify positions are increasing
for (i, pos) in recorded_positions.iter().enumerate() {
+24 -17
View File
@@ -149,7 +149,10 @@ impl QueueManager {
}
let insert_index = match position {
AddPosition::Next => self.current_index.map(|i| i + 1).unwrap_or(self.items.len()),
AddPosition::Next => self
.current_index
.map(|i| i + 1)
.unwrap_or(self.items.len()),
AddPosition::End => self.items.len(),
};
@@ -167,10 +170,7 @@ impl QueueManager {
// Regenerate shuffle order if shuffle is on
if self.shuffle {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
}
}
@@ -199,7 +199,8 @@ impl QueueManager {
// Update shuffle order
if self.shuffle {
self.shuffle_order = self.shuffle_order
self.shuffle_order = self
.shuffle_order
.iter()
.filter(|&&i| i != index)
.map(|&i| if i > index { i - 1 } else { i })
@@ -239,7 +240,10 @@ impl QueueManager {
} else if self.repeat == RepeatMode::All {
0
} else {
log::debug!("[Queue] next() at end of queue (index {}), no next track", current);
log::debug!(
"[Queue] next() at end of queue (index {}), no next track",
current
);
return None;
}
};
@@ -269,8 +273,11 @@ impl QueueManager {
if let Some(prev) = self.history.pop() {
// Safety check: ensure the history entry is valid
if prev >= self.items.len() {
log::warn!("[Queue] Invalid history entry {} (queue has {} items), clearing history",
prev, self.items.len());
log::warn!(
"[Queue] Invalid history entry {} (queue has {} items), clearing history",
prev,
self.items.len()
);
self.history.clear();
return None;
}
@@ -334,10 +341,7 @@ impl QueueManager {
self.shuffle = !self.shuffle;
if self.shuffle && !self.items.is_empty() {
self.shuffle_order = self.generate_shuffle_order(
self.items.len(),
self.current_index,
);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
} else {
self.shuffle_order.clear();
}
@@ -371,7 +375,8 @@ impl QueueManager {
true
} else if self.shuffle {
let pos = self.shuffle_order.iter().position(|&i| i == current);
pos.map(|p| p + 1 < self.shuffle_order.len()).unwrap_or(false)
pos.map(|p| p + 1 < self.shuffle_order.len())
.unwrap_or(false)
} else {
current + 1 < self.items.len()
}
@@ -473,8 +478,7 @@ impl QueueManager {
// Update shuffle order if shuffle is on
if self.shuffle && !self.shuffle_order.is_empty() {
// Regenerate shuffle order to maintain consistency
self.shuffle_order =
self.generate_shuffle_order(self.items.len(), self.current_index);
self.shuffle_order = self.generate_shuffle_order(self.items.len(), self.current_index);
}
true
@@ -486,7 +490,10 @@ impl QueueManager {
if let Some(current_index) = self.current_index {
if let Some(item) = self.items.get_mut(current_index) {
// Only update if it's a Remote source
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
if let MediaSource::Remote {
jellyfin_item_id, ..
} = &item.source
{
item.source = MediaSource::Remote {
stream_url: new_url,
jellyfin_item_id: jellyfin_item_id.clone(),
+30 -9
View File
@@ -1,3 +1,4 @@
use super::media::MediaItem;
/**
* Media Session Management
*
@@ -7,10 +8,8 @@
*
* See docs/architecture/01-rust-backend.md for the state machine diagram.
*/
use log::info;
use serde::{Deserialize, Serialize};
use super::media::MediaItem;
/// Media session type tracking the high-level playback context
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -98,7 +97,10 @@ impl MediaSessionManager {
/// Start an audio session with a queue
/// Transitions: Idle → Audio(active), Any → Audio(active)
pub fn start_audio_session(&mut self, first_item: MediaItem) {
info!("[MediaSession] Starting audio session: {}", first_item.title);
info!(
"[MediaSession] Starting audio session: {}",
first_item.title
);
self.current = MediaSessionType::Audio {
last_item: Some(first_item),
is_active: true,
@@ -107,7 +109,11 @@ impl MediaSessionManager {
/// Update audio session with new track (during playback)
pub fn update_audio_track(&mut self, item: MediaItem) {
if let MediaSessionType::Audio { last_item, is_active } = &mut self.current {
if let MediaSessionType::Audio {
last_item,
is_active,
} = &mut self.current
{
info!("[MediaSession] Updating audio track: {}", item.title);
*last_item = Some(item);
*is_active = true;
@@ -171,8 +177,14 @@ impl MediaSessionManager {
/// Advance to next episode in TV session
pub fn tv_session_next_episode(&mut self, next_item: MediaItem) {
if let MediaSessionType::TvShow { item, is_active, .. } = &mut self.current {
info!("[MediaSession] Advancing to next episode: {}", next_item.title);
if let MediaSessionType::TvShow {
item, is_active, ..
} = &mut self.current
{
info!(
"[MediaSession] Advancing to next episode: {}",
next_item.title
);
*item = next_item;
*is_active = true;
}
@@ -294,7 +306,10 @@ mod tests {
assert!(matches!(
manager.current(),
MediaSessionType::Audio { is_active: true, .. }
MediaSessionType::Audio {
is_active: true,
..
}
));
assert!(manager.should_show_miniplayer());
@@ -307,7 +322,10 @@ mod tests {
manager.audio_session_inactive();
assert!(matches!(
manager.current(),
MediaSessionType::Audio { is_active: false, .. }
MediaSessionType::Audio {
is_active: false,
..
}
));
assert!(manager.should_show_miniplayer()); // Still shows!
@@ -330,7 +348,10 @@ mod tests {
assert!(matches!(
manager.current(),
MediaSessionType::Movie { is_active: true, .. }
MediaSessionType::Movie {
is_active: true,
..
}
));
assert!(manager.should_show_video_player());
+3 -1
View File
@@ -199,7 +199,9 @@ mod tests {
#[test]
fn test_player_state_loading() {
let media = create_test_media_item("item-1", "Test Item");
let state = PlayerState::Loading { media: media.clone() };
let state = PlayerState::Loading {
media: media.clone(),
};
assert!(matches!(state, PlayerState::Loading { .. }));
assert_eq!(state.position(), None);
assert!(!state.is_playing());
File diff suppressed because it is too large Load Diff
+7 -10
View File
@@ -1,12 +1,12 @@
pub mod types;
pub mod online;
pub mod offline;
pub mod hybrid;
pub mod offline;
pub mod online;
pub mod types;
pub use types::*;
pub use online::{OnlineRepository, JRayActor};
pub use offline::OfflineRepository;
pub use hybrid::HybridRepository;
pub use offline::OfflineRepository;
pub use online::{JRayActor, OnlineRepository};
pub use types::*;
use async_trait::async_trait;
@@ -242,10 +242,7 @@ pub trait MediaRepository: Send + Sync {
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
/// @req: JA-019 - Get/create/update playlists
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError>;
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
/// Add items to a playlist
///
File diff suppressed because it is too large Load Diff
+357 -91
View File
@@ -1,15 +1,15 @@
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
use std::sync::Arc;
use async_trait::async_trait;
use log::{debug, error, info};
#[cfg(target_os = "android")]
use log::warn;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*};
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
///
@@ -108,22 +108,34 @@ impl OnlineRepository {
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Used by thumbnail cache to download images with proper auth and connection reuse.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
let request = self.http_client.client.get(url)
let request = self
.http_client
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| format!("Download failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
let body_preview = if body.len() > 200 {
&body[..200]
} else {
&body
};
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
}
response.bytes().await
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read bytes: {}", e))
}
@@ -132,7 +144,11 @@ impl OnlineRepository {
/// 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> {
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),
@@ -160,18 +176,29 @@ impl OnlineRepository {
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
async fn get_json_inner<T: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url)
let request = self
.http_client
.client
.get(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
@@ -197,9 +224,18 @@ impl OnlineRepository {
// Try to deserialize and log the raw JSON on error
serde_json::from_str(&text).map_err(|e| {
error!("[OnlineRepo] Failed to deserialize {} response: {}", endpoint, e);
error!("[OnlineRepo] Response body (first 1000 chars): {}",
if text.len() > 1000 { &text[..1000] } else { &text });
error!(
"[OnlineRepo] Failed to deserialize {} response: {}",
endpoint, e
);
error!(
"[OnlineRepo] Response body (first 1000 chars): {}",
if text.len() > 1000 {
&text[..1000]
} else {
&text
}
);
RepoError::Server {
message: format!("Failed to parse response: {}", e),
}
@@ -213,10 +249,17 @@ impl OnlineRepository {
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
async fn post_json_inner<T: Serialize>(
&self,
endpoint: &str,
body: &T,
) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url)
let request = self
.http_client
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.json(body)
@@ -225,8 +268,13 @@ impl OnlineRepository {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
@@ -268,7 +316,10 @@ impl OnlineRepository {
debug!("[HTTP] Request body:\n{}", json);
}
let request = self.http_client.client.post(&url)
let request = self
.http_client
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.json(body)
@@ -277,14 +328,22 @@ impl OnlineRepository {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
// Capture response body for error details
let error_body = response.text().await.unwrap_or_else(|_| "Failed to read error body".to_string());
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
error!("[HTTP] Error response ({}): {}", status, error_body);
if status.as_u16() == 401 || status.as_u16() == 403 {
@@ -325,8 +384,7 @@ impl OnlineRepository {
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds
.map(|seconds| (seconds * 10_000_000.0) as i64);
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
@@ -370,6 +428,69 @@ impl OnlineRepository {
Ok(url)
}
/// Get an **audio-only** stream URL for a *video* item, for the
/// background-audio handoff (UR-040).
///
/// TRACES: UR-040 | JA-032 | UT-059
///
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
/// the server extracts/transcodes only the item's audio track and streams
/// pure audio bytes — no video frames reach the device, so there is no client
/// video decode while backgrounded. Do NOT "optimize" this to reuse the
/// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
/// defeating the entire point of the feature.
///
/// `AudioStreamIndex` carries the user's currently-selected audio track over
/// from the video player; `StartTimeTicks` resumes at the handoff position.
/// `universal` lets the server pick direct-play vs transcode per codec/device.
///
/// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
/// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let audio_index = audio_stream_index.unwrap_or(0).to_string();
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("AudioStreamIndex", audio_index),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
("AudioCodec", "mp3".to_string()),
("TranscodingContainer", "mp3".to_string()),
("TranscodingProtocol", "http".to_string()),
("MaxStreamingBitrate", "384000".to_string()),
];
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
if let Some(seconds) = start_time_seconds {
let ticks = (seconds * 10_000_000.0) as i64;
params.push(("StartTimeTicks", ticks.to_string()));
}
let query = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
Ok(url)
}
}
// Jellyfin API response types (PascalCase from server)
@@ -707,7 +828,10 @@ impl MediaRepository for OnlineRepository {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
@@ -753,14 +877,19 @@ impl MediaRepository for OnlineRepository {
for item in items {
// Use album_id if available, fall back to album_name for grouping
let group_key = item.album_id.clone()
.or_else(|| item.album_name.clone());
let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
if let Some(key) = group_key {
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
debug!(
"[get_recently_played_audio] Grouping item '{}' into album '{}'",
item.name, key
);
album_map.entry(key).or_insert_with(Vec::new).push(item);
} else {
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
debug!(
"[get_recently_played_audio] No album_id or album_name for item: '{}'",
item.name
);
ungrouped.push(item);
}
}
@@ -770,17 +899,29 @@ impl MediaRepository for OnlineRepository {
.into_iter()
.map(|(album_id, tracks)| {
let first_track = &tracks[0];
let most_recent = tracks.iter()
let most_recent = tracks
.iter()
.max_by(|a, b| {
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_a = a
.user_data
.as_ref()
.and_then(|ud| ud.last_played_date.as_deref())
.unwrap_or("");
let date_b = b
.user_data
.as_ref()
.and_then(|ud| ud.last_played_date.as_deref())
.unwrap_or("");
date_b.cmp(date_a)
})
.unwrap_or(first_track);
MediaItem {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
name: first_track
.album_name
.clone()
.unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(),
@@ -820,9 +961,15 @@ impl MediaRepository for OnlineRepository {
// Return only the requested limit
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
debug!(
"[get_recently_played_audio] Returning {} items after grouping",
final_result.len()
);
for item in &final_result {
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
debug!(
"[get_recently_played_audio] Return: name={}, type={}",
item.name, item.item_type
);
}
Ok(final_result)
}
@@ -1061,8 +1208,8 @@ impl MediaRepository for OnlineRepository {
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.unwrap_or_else(|| {
let (video_codecs, audio_codecs) =
crate::player::get_detected_codecs().unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
@@ -1073,10 +1220,8 @@ impl MediaRepository for OnlineRepository {
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
let (video_codecs, audio_codecs) =
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
@@ -1139,25 +1284,31 @@ impl MediaRepository for OnlineRepository {
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: 0, // Request first audio stream
audio_stream_index: 0, // Request first audio stream
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
auto_open_live_stream: true,
max_streaming_bitrate: 20_000_000, // 20 Mbps
device_profile: Some(device_profile), // Now sending profile with detected codecs
max_streaming_bitrate: 20_000_000, // 20 Mbps
device_profile: Some(device_profile), // Now sending profile with detected codecs
};
let response: PlaybackInfoResponse = self.post_json_response(&endpoint, &request_body).await?;
let response: PlaybackInfoResponse =
self.post_json_response(&endpoint, &request_body).await?;
let source = response.media_sources.first().ok_or(RepoError::NotFound {
message: "No media sources available".to_string(),
})?;
// Log available media streams for debugging
info!("PlaybackInfo MediaSource has {} streams", source.media_streams.len());
info!(
"PlaybackInfo MediaSource has {} streams",
source.media_streams.len()
);
for stream in &source.media_streams {
info!(" Stream type={}, index={}, codec={:?}",
stream.stream_type, stream.index, stream.codec);
info!(
" Stream type={}, index={}, codec={:?}",
stream.stream_type, stream.index, stream.codec
);
}
// Use TranscodingUrl from response if available (Streamyfin pattern)
@@ -1265,12 +1416,15 @@ impl MediaRepository for OnlineRepository {
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
let source = response
.media_sources
.into_iter()
.next()
.ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
@@ -1410,11 +1564,7 @@ impl MediaRepository for OnlineRepository {
) -> String {
format!(
"{}/Videos/{}/{}/Subtitles/{}/{}",
self.server_url,
item_id,
media_source_id,
stream_index,
format
self.server_url, item_id, media_source_id, stream_index, format
)
}
@@ -1484,15 +1634,23 @@ impl MediaRepository for OnlineRepository {
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1578,15 +1736,18 @@ impl MediaRepository for OnlineRepository {
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
info!(
"[OnlineRepo] Creating playlist '{}' with {} items",
name,
item_ids.len()
);
let body = serde_json::json!({
"Name": name,
"Ids": item_ids,
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse =
self.post_json_response("/Playlists", &body).await?;
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
Ok(PlaylistCreatedResult { id: response.id })
}
@@ -1595,15 +1756,23 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Items/{}", playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1615,15 +1784,16 @@ impl MediaRepository for OnlineRepository {
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
info!(
"[OnlineRepo] Renaming playlist {} to '{}'",
playlist_id, name
);
let endpoint = format!("/Items/{}", playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
.await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
@@ -1675,15 +1845,23 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1719,9 +1897,8 @@ mod tests {
fn create_test_repository() -> OnlineRepository {
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = Arc::new(
HttpClient::new(http_config).expect("Failed to create HTTP client for test")
);
let http_client =
Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
OnlineRepository::new(
http_client,
"https://test.server.com".to_string(),
@@ -1757,9 +1934,15 @@ mod tests {
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
RepoError::Authentication {
message: "401".into(),
},
RepoError::NotFound {
message: "404".into(),
},
RepoError::Server {
message: "500".into(),
},
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
@@ -1789,7 +1972,12 @@ mod tests {
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
for err in [
RepoError::Database {
message: "cache".into(),
},
RepoError::Offline,
] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
@@ -1825,7 +2013,9 @@ mod tests {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
let result: Result<(), RepoError> = Err(RepoError::Network {
message: "timeout".into(),
});
repo.report_outcome(&result).await;
assert!(
@@ -1889,6 +2079,64 @@ mod tests {
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
// TRACES: UR-040 | JA-032 | UT-059
// Background-audio handoff must request an audio-only stream (no video
// decode) that resumes at the current position and keeps the selected
// audio track.
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
"expected audio-only universal endpoint, got: {url}"
);
// Must NOT be a video stream (no client video decode in background).
assert!(
!url.contains("/Videos/"),
"url must not hit the video endpoint: {url}"
);
assert!(
!url.contains("master.m3u8"),
"url must not be a video HLS playlist: {url}"
);
assert!(url.contains("AudioStreamIndex=2"));
assert!(url.contains("MediaSourceId=source-1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
// Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
// loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
assert!(
!url.contains("TranscodingProtocol=hls"),
"url must not be HLS: {url}"
);
assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
// TRACES: UR-040 | JA-032 | UT-059
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream.
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository();
@@ -1982,8 +2230,14 @@ mod tests {
// "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}");
assert!(
!url.contains("videoBitrate"),
"original must not transcode: {url}"
);
assert!(
!url.contains("maxHeight"),
"original must not transcode: {url}"
);
}
#[test]
@@ -1996,14 +2250,20 @@ mod tests {
url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}"
);
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {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}");
assert!(
!url.contains("Static=true"),
"{quality} must not be Static: {url}"
);
}
}
@@ -2036,7 +2296,10 @@ mod tests {
assert_eq!(item.name, "Test Album");
assert_eq!(item.item_type, "MusicAlbum");
assert!(item.image_tags.is_some());
assert_eq!(item.image_tags.unwrap().primary(), Some("tag123".to_string()));
assert_eq!(
item.image_tags.unwrap().primary(),
Some("tag123".to_string())
);
}
#[test]
@@ -2083,7 +2346,10 @@ mod tests {
assert_eq!(media_item.id, "album456");
assert_eq!(media_item.name, "Love and Theft");
assert_eq!(media_item.item_type, "MusicAlbum");
assert_eq!(media_item.primary_image_tag, Some("7ebab4f6a80cd09d".to_string()));
assert_eq!(
media_item.primary_image_tag,
Some("7ebab4f6a80cd09d".to_string())
);
assert_eq!(media_item.server_id, "test-server-id");
}
+13 -4
View File
@@ -561,7 +561,10 @@ mod tests {
// Verify serialization uses camelCase for frontend
let serialized = serde_json::to_string(&person).expect("Failed to serialize");
assert!(serialized.contains(r#""type":"Actor""#), "Serialized form should use 'type' not 'Type'");
assert!(
serialized.contains(r#""type":"Actor""#),
"Serialized form should use 'type' not 'Type'"
);
assert!(serialized.contains(r#""id":"person123""#));
assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
}
@@ -630,9 +633,15 @@ mod tests {
// Verify that when serialized to frontend, it uses camelCase
let serialized = serde_json::to_string(&item).expect("Failed to serialize");
let re_parsed: serde_json::Value = serde_json::from_str(&serialized).expect("Failed to parse serialized");
let people_array = re_parsed["people"].as_array().expect("people should be array");
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
let re_parsed: serde_json::Value =
serde_json::from_str(&serialized).expect("Failed to parse serialized");
let people_array = re_parsed["people"]
.as_array()
.expect("people should be array");
assert!(
people_array[0].get("type").is_some(),
"Serialized person should have 'type' field"
);
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
}
+72 -28
View File
@@ -7,14 +7,14 @@
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, info, warn};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::Duration;
use crate::jellyfin::JellyfinClient;
use crate::player::PlayerEventEmitter;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
use crate::player::PlayerEventEmitter;
/// Hint for adjusting poll frequency based on UI state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,6 +32,13 @@ pub struct SessionPollerManager {
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
playback_mode_manager: Arc<PlaybackModeManager>,
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
is_running: Arc<AtomicBool>,
@@ -52,6 +59,7 @@ impl SessionPollerManager {
jellyfin_client,
playback_mode_manager,
event_emitter: Arc::new(Mutex::new(None)),
connectivity_reporter: Arc::new(Mutex::new(None)),
is_running: Arc::new(AtomicBool::new(false)),
current_hint: Arc::new(RwLock::new(PollingHint::Normal)),
current_interval_ms: Arc::new(AtomicU64::new(10000)), // Default 10s
@@ -64,6 +72,13 @@ impl SessionPollerManager {
*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
pub fn start(&self) {
if self.is_running.swap(true, Ordering::Relaxed) {
@@ -77,6 +92,7 @@ impl SessionPollerManager {
let client = self.jellyfin_client.clone();
let mode_manager = self.playback_mode_manager.clone();
let emitter = self.event_emitter.clone();
let connectivity_reporter = self.connectivity_reporter.clone();
let is_running = self.is_running.clone();
let hint = self.current_hint.clone();
let interval_ms = self.current_interval_ms.clone();
@@ -87,27 +103,42 @@ impl SessionPollerManager {
while is_running.load(Ordering::Relaxed) {
// Calculate poll interval based on mode and hint
let new_interval = Self::calculate_interval(
&mode_manager.get_mode(),
*hint.read_safe(),
);
let new_interval =
Self::calculate_interval(&mode_manager.get_mode(), *hint.read_safe());
interval_ms.store(new_interval, Ordering::Relaxed);
debug!("[SessionPoller] Polling with interval: {}ms", new_interval);
// Fetch sessions
let sessions_result = rt.block_on(async {
// Fetch sessions. `had_client` distinguishes "server didn't
// 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();
match client_opt {
Some(c) => c.get_sessions().await,
Some(c) => (c.get_sessions().await, true),
None => {
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
match sessions_result {
Ok(sessions) => {
@@ -125,9 +156,7 @@ impl SessionPollerManager {
}
if let Some(em) = emitter.lock_safe().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions,
});
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { sessions });
}
}
Err(e) => {
@@ -168,10 +197,7 @@ impl SessionPollerManager {
/// 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,
) {
fn push_remote_lockscreen(sessions: &[crate::jellyfin::client::SessionInfo], session_id: &str) {
// 100ns Jellyfin ticks -> milliseconds.
const TICKS_PER_MS: i64 = 10_000;
@@ -218,7 +244,10 @@ impl SessionPollerManager {
};
if let Err(e) = crate::player::update_lockscreen_metadata(&meta) {
warn!("[SessionPoller] Failed to update lockscreen metadata: {}", e);
warn!(
"[SessionPoller] Failed to update lockscreen metadata: {}",
e
);
}
}
@@ -229,7 +258,7 @@ impl SessionPollerManager {
PollingHint::CastDiscovery => 15000, // Slow discovery
PollingHint::Normal => {
match mode {
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Remote { .. } => 2000, // Fast in remote mode
PlaybackMode::Local | PlaybackMode::Idle => 10000, // Default
}
}
@@ -238,7 +267,10 @@ impl SessionPollerManager {
/// Manually trigger a poll (for frontend refresh button)
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
let client = self.jellyfin_client.lock_safe().clone()
let client = self
.jellyfin_client
.lock_safe()
.clone()
.ok_or("Jellyfin client not configured")?;
client.get_sessions().await
@@ -269,7 +301,9 @@ mod tests {
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastActive
),
500
@@ -277,16 +311,24 @@ mod tests {
// CastDiscovery hint should always be 15s regardless of mode
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Idle, PollingHint::CastDiscovery),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(&PlaybackMode::Local, PollingHint::CastDiscovery),
SessionPollerManager::calculate_interval(
&PlaybackMode::Idle,
PollingHint::CastDiscovery
),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Local,
PollingHint::CastDiscovery
),
15000
);
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::CastDiscovery
),
15000
@@ -305,7 +347,9 @@ mod tests {
// Remote mode -> 2s
assert_eq!(
SessionPollerManager::calculate_interval(
&PlaybackMode::Remote { session_id: "test".to_string() },
&PlaybackMode::Remote {
session_id: "test".to_string()
},
PollingHint::Normal
),
2000
+26 -16
View File
@@ -128,7 +128,9 @@ impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> DbResult<usize> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
execute_query(&conn, query)
})
.await
@@ -139,7 +141,9 @@ impl DatabaseService for RusqliteService {
let conn = Arc::clone(&self.conn);
let sql = sql.to_string();
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute_batch(&sql)
.map_err(|e| format!("Execute batch failed: {}", e))
})
@@ -154,7 +158,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_one(&conn, query, mapper)
})
.await
@@ -168,7 +174,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_optional(&conn, query, mapper)
})
.await
@@ -182,7 +190,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_many(&conn, query, mapper)
})
.await
@@ -196,7 +206,9 @@ impl DatabaseService for RusqliteService {
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
@@ -224,7 +236,9 @@ impl DatabaseService for RusqliteService {
async fn last_insert_rowid(&self) -> DbResult<i64> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn.lock().map_err(|e| format!("Failed to lock connection: {}", e))?;
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
Ok(conn.last_insert_rowid())
})
.await
@@ -255,7 +269,9 @@ where
{
match query_one(conn, query, mapper) {
Ok(value) => Ok(Some(value)),
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => Ok(None),
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
Ok(None)
}
Err(e) => Err(e),
}
}
@@ -325,10 +341,7 @@ mod tests {
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
let query = Query::new("SELECT name FROM test WHERE id = 1");
let name: String = service
.query_one(query, |row| row.get(0))
.await
.unwrap();
let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
assert_eq!(name, "Bob");
}
@@ -346,10 +359,7 @@ mod tests {
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
let query = Query::new("SELECT name FROM test ORDER BY id");
let names: Vec<String> = service
.query_many(query, |row| row.get(0))
.await
.unwrap();
let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
assert_eq!(names, vec!["Alice", "Bob"]);
}
+36 -29
View File
@@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use rusqlite::{Connection, Result as SqliteResult};
use schema::MIGRATIONS;
pub use db_service::{DatabaseService, RusqliteService};
use schema::MIGRATIONS;
/// Database connection wrapper with thread-safe access
pub struct Database {
@@ -113,10 +113,7 @@ impl Database {
match conn.execute_batch(sql) {
Ok(_) => {
info!("Successfully applied migration: {}", name);
match conn.execute(
"INSERT INTO _migrations (name) VALUES (?1)",
[name],
) {
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
Ok(_) => debug!("Recorded migration: {}", name),
Err(e) => {
error!("Failed to record migration {}: {}", name, e);
@@ -264,9 +261,11 @@ mod tests {
.unwrap();
let name: String = conn
.query_row("SELECT name FROM servers WHERE id = ?1", ["server1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT name FROM servers WHERE id = ?1",
["server1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(name, "Updated Server");
@@ -275,7 +274,9 @@ mod tests {
.unwrap();
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| row.get(0))
.query_row("SELECT COUNT(*) FROM servers", [], |row: &rusqlite::Row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 0);
}
@@ -313,16 +314,15 @@ mod tests {
assert_eq!(is_active, 1);
// Update is_active
conn.execute(
"UPDATE users SET is_active = 0 WHERE id = ?1",
["user1"],
)
.unwrap();
conn.execute("UPDATE users SET is_active = 0 WHERE id = ?1", ["user1"])
.unwrap();
let is_active: i32 = conn
.query_row("SELECT is_active FROM users WHERE id = ?1", ["user1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT is_active FROM users WHERE id = ?1",
["user1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(is_active, 0);
}
@@ -348,9 +348,11 @@ mod tests {
// Verify user exists
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE server_id = ?1", ["server1"], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE server_id = ?1",
["server1"],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
@@ -360,7 +362,9 @@ mod tests {
// User should be deleted via CASCADE
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| row.get(0))
.query_row("SELECT COUNT(*) FROM users", [], |row: &rusqlite::Row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 0);
}
@@ -677,15 +681,16 @@ mod tests {
// Initially both users are active (simulating the old bug)
let active_count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE is_active = 1",
[],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(active_count, 2);
// Now simulate setting user1 as active (global deactivation)
conn.execute("UPDATE users SET is_active = 0", [])
.unwrap();
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
conn.execute(
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?1",
["user1"],
@@ -694,9 +699,11 @@ mod tests {
// Only one user should be active now
let active_count: i32 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_active = 1", [], |row: &rusqlite::Row| {
row.get(0)
})
.query_row(
"SELECT COUNT(*) FROM users WHERE is_active = 1",
[],
|row: &rusqlite::Row| row.get(0),
)
.unwrap();
assert_eq!(active_count, 1);
+47 -22
View File
@@ -75,7 +75,10 @@ impl ThumbnailCache {
],
);
if let Ok(Some(path_str)) = db.query_optional(exact, |row| row.get::<_, String>(0)).await {
if let Ok(Some(path_str)) = db
.query_optional(exact, |row| row.get::<_, String>(0))
.await
{
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, Some(tag)).await;
@@ -83,14 +86,16 @@ impl ThumbnailCache {
}
// 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;
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
@@ -204,7 +209,11 @@ impl ThumbnailCache {
}
/// Ensure there's enough space by evicting LRU items if needed
async fn ensure_space(&self, db: Arc<RusqliteService>, needed_bytes: u64) -> Result<(), String> {
async fn ensure_space(
&self,
db: Arc<RusqliteService>,
needed_bytes: u64,
) -> Result<(), String> {
let max_size = {
let config = self.config.lock().map_err(|e| e.to_string())?;
config.max_size_bytes
@@ -236,9 +245,7 @@ impl ThumbnailCache {
);
let items: Vec<(i64, String, i64)> = db
.query_many(query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.await
.map_err(|e| e.to_string())?;
@@ -276,9 +283,7 @@ impl ThumbnailCache {
pub async fn get_item_count(&self, db: Arc<RusqliteService>) -> i64 {
let query = Query::new("SELECT COUNT(*) FROM thumbnails");
db.query_one(query, |row| row.get(0))
.await
.unwrap_or(0)
db.query_one(query, |row| row.get(0)).await.unwrap_or(0)
}
/// Get the current cache limit in bytes
@@ -297,7 +302,11 @@ impl ThumbnailCache {
}
/// Set the cache limit in bytes
pub async fn set_limit(&self, db: Arc<RusqliteService>, limit_bytes: u64) -> Result<(), String> {
pub async fn set_limit(
&self,
db: Arc<RusqliteService>,
limit_bytes: u64,
) -> Result<(), String> {
// Update database setting
let query = Query::with_params(
"INSERT OR REPLACE INTO cache_settings (key, value, updated_at)
@@ -444,14 +453,24 @@ mod tests {
// Save a thumbnail
let data = b"fake image data";
let path = cache
.save_thumbnail(conn.clone(), "item1", "Primary", "tag1", data, Some(100), Some(100))
.save_thumbnail(
conn.clone(),
"item1",
"Primary",
"tag1",
data,
Some(100),
Some(100),
)
.await
.unwrap();
assert!(path.exists());
// Get cached path
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_some());
assert_eq!(cached.unwrap(), path);
}
@@ -461,7 +480,9 @@ mod tests {
let (conn, temp_dir) = setup_test_db();
let cache = ThumbnailCache::new(temp_dir.path().to_path_buf(), CacheConfig::default());
let cached = cache.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "nonexistent", "Primary", "tag1")
.await;
assert!(cached.is_none());
}
@@ -488,11 +509,15 @@ mod tests {
.unwrap();
// First item should be evicted
let cached = cache.get_cached_path(conn.clone(), "item1", "Primary", "tag1").await;
let cached = cache
.get_cached_path(conn.clone(), "item1", "Primary", "tag1")
.await;
assert!(cached.is_none());
// Second item should exist
let cached = cache.get_cached_path(conn.clone(), "item2", "Primary", "tag2").await;
let cached = cache
.get_cached_path(conn.clone(), "item2", "Primary", "tag2")
.await;
assert!(cached.is_some());
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.1.0",
"version": "0.0.16",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+80 -1
View File
@@ -19,6 +19,40 @@ export const commands = {
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_item", { item });
},
/**
* Enter background-audio mode: hand playback of the currently-watched video off
* to the native ExoPlayer *audio* path so the audio keeps playing while the app
* is backgrounded/locked, with no client-side video decode (UR-040).
*
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
* honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
* The sleep-timer state is intentionally left untouched by the handoff.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched if it fired while backgrounded, playback is already stopped and
* this simply reports the last position.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerExitBackgroundAudio() : Promise<number> {
return await TAURI_INVOKE("player_exit_background_audio");
},
/**
* Play a queue of media items
*
@@ -825,6 +859,19 @@ async syncFullCatalog(handle: string) : Promise<CatalogSyncResult> {
async catalogSyncStatus() : Promise<CatalogSyncStatus> {
return await TAURI_INVOKE("catalog_sync_status");
},
/**
* Control whether offline library queries reveal the full synced catalog
* (greyed-out, non-downloaded media) or only downloaded/local media.
*
* The frontend calls this from the "Show all server media" toggle: pass `true`
* when online, or when offline with the toggle on; pass `false` when offline
* with the toggle off so library pages show downloaded media only. Fixes the
* bug where offline library pages showed every server item regardless of the
* toggle.
*/
async setShowServerCatalog(show: boolean) : Promise<void> {
await TAURI_INVOKE("set_show_server_catalog", { show });
},
/**
* 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
@@ -1193,6 +1240,14 @@ async repositoryGetVideoStreamUrl(handle: string, itemId: string, mediaSourceId:
async repositoryGetAudioStreamUrl(handle: string, itemId: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_stream_url", { handle, itemId });
},
/**
* Get an audio-only stream URL for a *video* item (background-audio handoff).
*
* TRACES: UR-040 | JA-032 | UT-061
*/
async repositoryGetAudioOnlyStreamUrlForVideo(handle: string, itemId: string, mediaSourceId: string | null, startTimeSeconds: number | null, audioStreamIndex: number | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_audio_only_stream_url_for_video", { handle, itemId, mediaSourceId, startTimeSeconds, audioStreamIndex });
},
/**
* Get Live TV channels (broadcast / IPTV) for browsing
*/
@@ -1747,7 +1802,19 @@ videoCodec: string;
/**
* Whether the video requires server-side transcoding
*/
needsTranscoding: boolean }
needsTranscoding: boolean;
/**
* Optional now-playing metadata. Used by the background-audio handoff so the
* lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
* existing video-only callers need not send them.
*/
artist?: string | null; primaryImageTag?: string | null; serverId?: string | null;
/**
* Total media duration (seconds). Threaded through the background-audio
* handoff so the lockscreen MediaSession advertises a real duration a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null }
/**
* Queue context for remote transfer - what type of queue is this?
*/
@@ -2035,6 +2102,18 @@ export type PlayerStatusEvent =
* Remote sessions updated (for cast/remote control UI)
*/
{ type: "sessions_updated"; sessions: SessionInfo[] } |
/**
* The authoritative playback mode changed in the Rust backend.
*
* The Rust `PlaybackModeManager` is the single source of truth for which
* device playback commands route to (local vs a remote session). The
* frontend keeps a mirror store for the UI; without this event that mirror
* drifts out of sync (e.g. a mode transition happens inside a transfer or a
* local stop that the frontend never learns about), and controls then route
* to the wrong device the classic "it keeps playing on the remote" bug.
* The frontend reconciles its store to this payload whenever it fires.
*/
{ type: "playback_mode_changed"; mode: string; session_id: string | null } |
/**
* The user asked to disconnect from the remote session and resume locally.
*
+32
View File
@@ -390,6 +390,38 @@ describe("RepositoryClient", () => {
});
});
it("should get audio-only stream URL for a video item (camelCase params)", async () => {
// TRACES: UR-040 | JA-032 | UT-061
const mockUrl = "https://server.com/Audio/item123/universal?AudioStreamIndex=2";
(invoke as any).mockResolvedValueOnce(mockUrl);
const url = await client.getAudioOnlyStreamUrlForVideo("item123", "source456", 193, 2);
expect(url).toBe(mockUrl);
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: "source456",
startTimeSeconds: 193,
audioStreamIndex: 2,
});
});
it("should default optional params to null for audio-only stream URL", async () => {
// TRACES: UR-040 | JA-032 | UT-061
(invoke as any).mockResolvedValueOnce("https://server.com/Audio/item123/universal");
await client.getAudioOnlyStreamUrlForVideo("item123");
expect(invoke).toHaveBeenCalledWith("repository_get_audio_only_stream_url_for_video", {
handle: "test-handle-123",
itemId: "item123",
mediaSourceId: null,
startTimeSeconds: null,
audioStreamIndex: null,
});
});
it("should report playback progress", async () => {
(invoke as any).mockResolvedValueOnce(undefined);
+20
View File
@@ -172,6 +172,26 @@ export class RepositoryClient {
);
}
/**
* Audio-only stream URL for a video item, for the background-audio handoff.
* The server extracts just the audio track no video is decoded on-device.
* TRACES: UR-040 | JA-032
*/
async getAudioOnlyStreamUrlForVideo(
itemId: string,
mediaSourceId?: string,
startTimeSeconds?: number,
audioStreamIndex?: number
): Promise<string> {
return commands.repositoryGetAudioOnlyStreamUrlForVideo(
this.ensureHandle(),
itemId,
mediaSourceId ?? null,
startTimeSeconds ?? null,
audioStreamIndex ?? null
);
}
// ===== Live TV / Channels =====
/** Browse Live TV channels (broadcast / IPTV). */
+64
View File
@@ -0,0 +1,64 @@
<!--
BottomUi — the app's bottom UI (mini player stacked over the bottom nav).
Rendered as an IN-FLOW flex child at the bottom of a full-height flex column,
NOT a fixed overlay. This is the whole point: because it is a normal flex
sibling below the scroll container (which is `flex-1 min-h-0 overflow-y-auto`),
the scroller is physically bounded above it and can never render behind it.
This replaces the old ResizeObserver + `bottomUiHeight` + padding-reservation
scheme, which started at 0, updated async, and repeatedly regressed into the
"last row hidden behind the nav" bug. There is nothing to measure or reserve:
the browser's flex layout does it exactly, every frame.
The Android system gesture bar is cleared via `env(safe-area-inset-bottom)`.
TRACES: UR-005 | DR-009
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { currentMedia, isPlaying, playbackPosition, playbackDuration } from "$lib/stores/player";
import { isShuffle, repeatMode, hasNext, hasPrevious } from "$lib/stores/queue";
import { showSleepTimerModal } from "$lib/stores/appState";
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
import BottomNav from "$lib/components/BottomNav.svelte";
let {
showMiniPlayer = true,
showNav = true,
onExpand,
}: {
showMiniPlayer?: boolean;
showNav?: boolean;
// Where "expand mini player" goes. Defaults to the full player route.
onExpand?: () => void;
} = $props();
function expand() {
if (onExpand) return onExpand();
if ($currentMedia) goto(`/player/${$currentMedia.id}`);
}
</script>
<!-- flex-shrink-0 so it keeps its natural height; the scroller sibling flexes. -->
<div class="flex-shrink-0 pb-[env(safe-area-inset-bottom)] bg-[var(--color-surface)]">
{#if showMiniPlayer}
<MiniPlayer
media={$currentMedia}
isPlaying={$isPlaying}
position={$playbackPosition}
duration={$playbackDuration}
shuffle={$isShuffle}
repeat={$repeatMode}
hasNext={$hasNext}
hasPrevious={$hasPrevious}
className="flex-shrink-0"
onExpand={expand}
onSleepTimerClick={() => showSleepTimerModal.set(true)}
/>
{/if}
{#if showNav}
<BottomNav className="flex-shrink-0" />
{/if}
</div>
@@ -1,432 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, waitFor } from "@testing-library/svelte";
/**
* Integration tests for async image loading pattern used in components
*
* Pattern:
* - Component has $state<string> imageUrl = ""
* - Component has async loadImageUrl() function
* - Component uses $effect to call loadImageUrl when dependencies change
* - For lists: uses Map<string, string> to cache URLs per item
*/
// Mock repository with getImageUrl
const createMockRepository = () => ({
getImageUrl: vi.fn(),
});
describe.skip("Async Image Loading Pattern", () => {
// Detailed async pattern tests - core functionality verified in repository-client.test.ts
let mockRepository: any;
beforeEach(() => {
mockRepository = createMockRepository();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Single Image Loading", () => {
it("should load image URL asynchronously on component mount", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulating component with async image loading
const imageUrl = await mockRepository.getImageUrl("item123", "Primary");
expect(imageUrl).toBe("https://server.com/image.jpg");
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item123", "Primary");
});
it("should show placeholder while loading", async () => {
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve("https://server.com/image.jpg"), 100))
);
vi.useFakeTimers();
const promise = mockRepository.getImageUrl("item123", "Primary");
// Initially no URL
expect(promise).toBeInstanceOf(Promise);
vi.advanceTimersByTime(100);
vi.useRealTimers();
const result = await promise;
expect(result).toBe("https://server.com/image.jpg");
});
it("should reload image when item changes", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image1.jpg");
const url1 = await mockRepository.getImageUrl("item1", "Primary");
expect(url1).toBe("https://server.com/image1.jpg");
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
const url2 = await mockRepository.getImageUrl("item2", "Primary");
expect(url2).toBe("https://server.com/image2.jpg");
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should not reload image if item ID hasn't changed", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// First load
await mockRepository.getImageUrl("item123", "Primary");
// Would normally use $effect to track changes
// If item ID is same, should not reload (handled by component caching)
// This test documents the expected behavior
});
it("should handle load errors gracefully", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
// Component should catch error and show placeholder
try {
await mockRepository.getImageUrl("item123", "Primary");
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
});
});
describe("List Image Caching (Map-based)", () => {
it("should cache URLs using Map<string, string>", () => {
// Simulating component state: imageUrls = $state<Map<string, string>>(new Map())
const imageUrls = new Map<string, string>();
// Load first item
imageUrls.set("item1", "https://server.com/image1.jpg");
expect(imageUrls.has("item1")).toBe(true);
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
// Load second item
imageUrls.set("item2", "https://server.com/image2.jpg");
expect(imageUrls.size).toBe(2);
// Check cache hit
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
});
it("should load images only once per item", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
const imageUrls = new Map<string, string>();
// Simulate loading multiple items
const items = [
{ id: "item1", name: "Album 1" },
{ id: "item2", name: "Album 2" },
{ id: "item1", name: "Album 1 (again)" }, // Same ID
];
for (const item of items) {
if (!imageUrls.has(item.id)) {
const url = await mockRepository.getImageUrl(item.id, "Primary");
imageUrls.set(item.id, url);
}
}
// Should only call once per unique ID
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should update single item without affecting others", async () => {
const imageUrls = new Map<string, string>();
imageUrls.set("item1", "https://server.com/image1.jpg");
imageUrls.set("item2", "https://server.com/image2.jpg");
imageUrls.set("item3", "https://server.com/image3.jpg");
// Update item2
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2_updated.jpg");
const newUrl = await mockRepository.getImageUrl("item2", "Primary");
imageUrls.set("item2", newUrl);
// Others should remain unchanged
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
expect(imageUrls.get("item2")).toBe("https://server.com/image2_updated.jpg");
expect(imageUrls.get("item3")).toBe("https://server.com/image3.jpg");
});
it("should clear cache when data changes", () => {
const imageUrls = new Map<string, string>();
imageUrls.set("item1", "https://server.com/image1.jpg");
imageUrls.set("item2", "https://server.com/image2.jpg");
// Clear cache
imageUrls.clear();
expect(imageUrls.size).toBe(0);
expect(imageUrls.has("item1")).toBe(false);
});
it("should support Map operations efficiently", () => {
const imageUrls = new Map<string, string>();
// Add items
for (let i = 0; i < 100; i++) {
imageUrls.set(`item${i}`, `https://server.com/image${i}.jpg`);
}
expect(imageUrls.size).toBe(100);
// Check specific item
expect(imageUrls.has("item50")).toBe(true);
expect(imageUrls.get("item50")).toBe("https://server.com/image50.jpg");
// Iterate
let count = 0;
imageUrls.forEach(() => {
count++;
});
expect(count).toBe(100);
});
});
describe("Component Lifecycle ($effect integration)", () => {
it("should trigger load on prop change", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate $effect tracking prop changes
let effectCount = 0;
const trackingEffect = vi.fn(() => {
effectCount++;
return mockRepository.getImageUrl("item123", "Primary");
});
trackingEffect();
expect(effectCount).toBe(1);
trackingEffect();
expect(effectCount).toBe(2);
});
it("should skip load if conditions not met", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate conditional loading (e.g., if (!imageUrl && primaryImageTag))
let imageUrl = "";
const primaryImageTag = "";
if (!imageUrl && primaryImageTag) {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
}
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
});
it("should handle dependent state updates", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate component state changes triggering effects
const state = {
item: { id: "item1", primaryImageTag: "tag1" },
imageUrl: "",
};
const loadImage = async () => {
if (state.item.primaryImageTag) {
state.imageUrl = await mockRepository.getImageUrl(state.item.id, "Primary");
}
};
await loadImage();
expect(state.imageUrl).toBe("https://server.com/image.jpg");
// Change item
state.item = { id: "item2", primaryImageTag: "tag2" };
state.imageUrl = "";
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
await loadImage();
expect(state.imageUrl).toBe("https://server.com/image2.jpg");
});
});
describe("Error Handling in Async Loading", () => {
it("should set empty string on error", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
let imageUrl = "";
try {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
} catch {
imageUrl = ""; // Set to empty on error
}
expect(imageUrl).toBe("");
});
it("should allow retry after error", async () => {
mockRepository.getImageUrl
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce("https://server.com/image.jpg");
let imageUrl = "";
// First attempt fails
try {
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
} catch {
imageUrl = "";
}
// Retry succeeds
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
expect(imageUrl).toBe("https://server.com/image.jpg");
});
it("should handle concurrent load requests", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate loading multiple images concurrently
const imageUrls = new Map<string, string>();
const items = [
{ id: "item1" },
{ id: "item2" },
{ id: "item3" },
];
const promises = items.map(item =>
mockRepository.getImageUrl(item.id, "Primary")
.then((url: string) => imageUrls.set(item.id, url))
.catch(() => imageUrls.set(item.id, ""))
);
await Promise.all(promises);
expect(imageUrls.size).toBe(3);
expect(imageUrls.has("item1")).toBe(true);
expect(imageUrls.has("item2")).toBe(true);
expect(imageUrls.has("item3")).toBe(true);
});
});
describe("Performance Characteristics", () => {
it("should not reload unnecessarily", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
// Simulate $effect with dependency tracking
let dependencyValue = "same";
let previousDependency = "same";
const loadImage = async () => {
if (dependencyValue !== previousDependency) {
previousDependency = dependencyValue;
return await mockRepository.getImageUrl("item123", "Primary");
}
};
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
// No change in dependency
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
// Change dependency
dependencyValue = "changed";
await loadImage();
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
it("should handle large lists efficiently", async () => {
const imageUrls = new Map<string, string>();
let loadCount = 0;
mockRepository.getImageUrl.mockImplementation(() => {
loadCount++;
return Promise.resolve("https://server.com/image.jpg");
});
// Simulate loading 1000 items but caching URLs
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `item${i % 10}` }));
for (const item of items) {
if (!imageUrls.has(item.id)) {
const url = await mockRepository.getImageUrl(item.id, "Primary");
imageUrls.set(item.id, url);
}
}
// Should only load 10 unique images
expect(loadCount).toBe(10);
expect(imageUrls.size).toBe(10);
});
it("should not block rendering during async loading", () => {
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) =>
setTimeout(() => resolve("https://server.com/image.jpg"), 1000)
)
);
// Async operation should not block component rendering
const renderTiming = {
startRender: Date.now(),
loadStart: null as number | null,
loadComplete: null as number | null,
};
// Render happens immediately
renderTiming.startRender = Date.now();
// Load happens asynchronously
mockRepository.getImageUrl("item123", "Primary").then(() => {
renderTiming.loadComplete = Date.now();
});
// Render should complete before load finishes
expect(Date.now() - renderTiming.startRender).toBeLessThan(1000);
});
});
describe("Backend Integration", () => {
it("should call backend with correct parameters", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
await mockRepository.getImageUrl("item123", "Primary", {
maxWidth: 300,
});
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
it("should handle backend URL correctly", async () => {
const backendUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
mockRepository.getImageUrl.mockResolvedValue(backendUrl);
const url = await mockRepository.getImageUrl("item123", "Primary", { maxWidth: 300 });
expect(url).toBe(backendUrl);
// Frontend never constructs URLs directly
expect(url).toContain("api_key=");
});
it("should not require URL construction in frontend", async () => {
// Frontend receives pre-constructed URL from backend
const preConstructedUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(preConstructedUrl);
const url = await mockRepository.getImageUrl("item123", "Primary");
// Frontend just uses the URL
expect(url).toContain("https://");
expect(url).toContain("item123");
});
});
});
@@ -3,7 +3,7 @@
import { page } from "$app/stores";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import SearchBar from "$lib/components/common/SearchBar.svelte";
@@ -138,7 +138,7 @@
selectedGenre = null;
genreItems = [];
} else {
navigateBack(config.backPath);
navigateUp(config.backPath);
}
}
@@ -0,0 +1,154 @@
/**
* Regression test: media-list search must surface server results.
*
* `repository_search` is two-phase `repo.search()` resolves instantly with
* cache-only (downloaded) results, and the merged cache+server union arrives
* later via a `search-event`. A consumer that ignores that event only ever
* shows downloaded content, so search "finds nothing" for un-downloaded media.
*
* This test models that two-phase backend faithfully and would fail against a
* version of GenericMediaListPage that does not subscribe to `search-event`.
*
* TRACES: UR-008
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import GenericMediaListPage from "./GenericMediaListPage.svelte";
import type { MediaListConfig } from "./GenericMediaListPage.svelte";
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/stores/library", () => ({
currentLibrary: {
subscribe: vi.fn((fn) => {
fn({ id: "lib123", name: "Music" });
return vi.fn();
}),
},
// Consumed as a store ($viewMode) by LibraryGrid, which this page renders.
viewMode: {
subscribe: vi.fn((fn) => {
fn("grid");
return vi.fn();
}),
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getRepository: vi.fn() },
}));
vi.mock("$lib/composables/useServerReachabilityReload", () => ({
useServerReachabilityReload: vi.fn(() => ({ markLoaded: vi.fn() })),
}));
// Capture the `search-event` handler the component registers so the test can
// drive the deferred (server) phase manually.
let searchEventHandler: ((event: { payload: unknown }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, handler: (event: { payload: unknown }) => void) => {
if (name === "search-event") searchEventHandler = handler;
return () => {};
}),
}));
const ALBUM_CONFIG: MediaListConfig = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "grid",
};
describe("GenericMediaListPage — two-phase search", () => {
beforeEach(() => {
searchEventHandler = null;
vi.clearAllMocks();
});
it("renders server results that arrive after the cache-only phase", async () => {
// Phase 1 (synchronous) returns cache-only — empty, as it is for a user who
// has downloaded nothing. This is the exact condition that used to show
// "nothing found" even though the server has matching albums.
let capturedRequestId: number | undefined;
const search = vi.fn(async (_q: string, _opts: unknown, requestId: number) => {
capturedRequestId = requestId;
return { items: [], totalRecordCount: 0 };
});
const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems,
search,
} as any);
const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } });
// Let the initial (mount) load finish so the debounced search effect is armed.
await waitFor(() => expect(getItems).toHaveBeenCalled());
const input = container.querySelector("input") as HTMLInputElement;
fireEvent.input(input, { target: { value: "Rumours" } });
// Debounced search fires after 300ms and returns the empty cache result.
// The `search-event` listener is registered lazily as part of searching.
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// Cache-only phase: nothing to show yet (the results counter reads zero).
await waitFor(() =>
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
);
// Phase 2: backend emits the merged cache+server union for this request.
expect(capturedRequestId).toBeTypeOf("number");
searchEventHandler!({
payload: {
requestId: capturedRequestId,
result: {
items: [{ id: "album1", name: "Rumours", type: "MusicAlbum" }],
totalRecordCount: 1,
},
},
});
// The server result must now be reflected in the list. Old code (no
// listener) never reached this state — the count stayed at zero.
await waitFor(() =>
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
);
});
it("ignores a search-event whose requestId is stale", async () => {
const search = vi.fn(async () => ({ items: [], totalRecordCount: 0 }));
const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems,
search,
} as any);
const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } });
await waitFor(() => expect(getItems).toHaveBeenCalled());
const input = container.querySelector("input") as HTMLInputElement;
fireEvent.input(input, { target: { value: "Rumours" } });
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// A superseded query's late result (wrong requestId) must not render.
searchEventHandler!({
payload: {
requestId: -999,
result: {
items: [{ id: "stale", name: "Stale Album", type: "MusicAlbum" }],
totalRecordCount: 1,
},
},
});
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Stale Album")).toBeNull();
});
});
@@ -1,8 +1,9 @@
<!-- TRACES: UR-007, UR-029, UR-030 | DR-007, DR-032, DR-033 -->
<script lang="ts">
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
@@ -13,7 +14,7 @@
import BackButton from "$lib/components/common/BackButton.svelte";
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import type { MediaItem, Library, ItemType } from "$lib/api/types";
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
import AlphabetScrollBar from "./AlphabetScrollBar.svelte";
@@ -54,6 +55,31 @@
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let initialLoadDone = false;
/**
* Payload of the backend `search-event` (mirrors Rust `SearchUpdateEvent`).
* `repo.search()` resolves instantly with cache-only (downloaded) results;
* the merged cache+server union arrives later via this event.
*/
interface SearchUpdateEvent {
requestId: number;
result: SearchResult;
}
// Monotonic id identifying the latest search request. The deferred
// `search-event` is only applied when its requestId still matches, so
// out-of-order / superseded server results never clobber fresher ones.
let searchRequestId = 0;
let unlistenSearch: UnlistenFn | null = null;
async function ensureSearchListener() {
if (unlistenSearch) return;
unlistenSearch = await listen<SearchUpdateEvent>("search-event", (event) => {
const { requestId, result } = event.payload;
if (requestId !== searchRequestId) return;
items = excludePodcasts(result.items);
});
}
$effect(() => {
sortBy = config.defaultSort;
});
@@ -82,12 +108,26 @@
// Use backend search if search query is provided, otherwise use getItems with sort
// HACK: excludePodcasts drops the "Podcasts" folder stored in the music library.
if (debouncedSearchQuery.trim()) {
const result = await repo.search(debouncedSearchQuery, {
includeItemTypes: [config.itemType],
limit: 10000,
});
items = excludePodcasts(result.items);
// Phase 1: instant cache-only (downloaded) results. The merged
// cache+server union arrives later via the `search-event` listener,
// tagged with this requestId so superseded queries are ignored.
await ensureSearchListener();
const requestId = ++searchRequestId;
const result = await repo.search(
debouncedSearchQuery,
{
includeItemTypes: [config.itemType],
limit: 10000,
},
requestId
);
// Only apply if this is still the active query.
if (requestId === searchRequestId) {
items = excludePodcasts(result.items);
}
} else {
// Leaving search — invalidate any in-flight server results.
searchRequestId++;
const result = await repo.getItems($currentLibrary.id, {
includeItemTypes: [config.itemType],
sortBy,
@@ -120,6 +160,11 @@
}, 300);
});
onDestroy(() => {
if (unlistenSearch) unlistenSearch();
if (searchTimeout) clearTimeout(searchTimeout);
});
function handleSort(newSort: string) {
sortBy = newSort;
loadItems();
@@ -131,7 +176,7 @@
}
function goBack() {
navigateBack(config.backPath);
navigateUp(config.backPath);
}
const searchPlaceholder = $derived(config.searchPlaceholder || `Search ${config.title.toLowerCase()}...`);
@@ -15,6 +15,13 @@ vi.mock("$lib/stores/library", () => ({
return vi.fn();
}),
},
// Consumed as a store ($viewMode) by LibraryGrid, which this page renders.
viewMode: {
subscribe: vi.fn((fn) => {
fn("grid");
return vi.fn();
}),
},
}));
vi.mock("$lib/stores/auth", () => ({
@@ -32,7 +39,12 @@ vi.mock("$lib/composables/useServerReachabilityReload", () => ({
})),
}));
describe.skip("GenericMediaListPage", () => {
// The component lazily subscribes to the backend `search-event` when searching.
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
describe("GenericMediaListPage", () => {
// Component integration tests - core sorting/search/debouncing logic tested in backend-integration.test.ts
beforeEach(() => {
vi.clearAllMocks();
@@ -66,6 +78,16 @@ describe.skip("GenericMediaListPage", () => {
});
it("should load items on mount", async () => {
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems: mockGetItemsFn,
search: vi.fn(),
} as any);
const config = {
itemType: "Audio" as const,
title: "Tracks",
@@ -80,9 +102,7 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
await waitFor(() => {
// loadItems should have been called
});
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.anything()));
});
it("should display sort options", () => {
@@ -111,8 +131,14 @@ describe.skip("GenericMediaListPage", () => {
});
describe("Search Functionality", () => {
it("should debounce search input for 300ms", async () => {
vi.useFakeTimers();
it("should debounce rapid keystrokes into a single search for the final value", async () => {
const mockSearchFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems: mockGetItemsFn,
search: mockSearchFn,
} as any);
const config = {
itemType: "Audio" as const,
@@ -128,29 +154,33 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
// Let the mount load settle so the debounce effect is armed.
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled());
// Drive the debounce window deterministically with fake timers, flushing
// the async loadItems() microtasks after the timer fires.
vi.useFakeTimers();
const searchInput = container.querySelector("input") as HTMLInputElement;
// Type into search
fireEvent.input(searchInput, { target: { value: "t" } });
expect(searchInput.value).toBe("t");
// Search should not trigger immediately
vi.advanceTimersByTime(100);
// Add more characters
await vi.advanceTimersByTimeAsync(100);
fireEvent.input(searchInput, { target: { value: "te" } });
await vi.advanceTimersByTimeAsync(100);
fireEvent.input(searchInput, { target: { value: "tes" } });
await vi.advanceTimersByTimeAsync(100);
fireEvent.input(searchInput, { target: { value: "test" } });
// Still shouldn't trigger (only 100ms passed total)
vi.advanceTimersByTime(100);
// Now advance to 300ms total - search should trigger
vi.advanceTimersByTime(100);
await waitFor(() => {
// Search should have been debounced
});
// 200ms after the final keystroke: still inside the 300ms window, so no
// search has fired despite four keystrokes.
await vi.advanceTimersByTimeAsync(200);
expect(mockSearchFn).not.toHaveBeenCalled();
// Cross the threshold: exactly one search, for the final value.
await vi.advanceTimersByTimeAsync(100);
vi.useRealTimers();
expect(mockSearchFn).toHaveBeenCalledTimes(1);
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.anything(), expect.any(Number));
});
it("should use backend search when search query is provided", async () => {
@@ -159,8 +189,13 @@ describe.skip("GenericMediaListPage", () => {
totalRecordCount: 1,
});
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: vi.fn(),
getItems: mockGetItemsFn,
search: mockSearchFn,
};
@@ -178,25 +213,27 @@ describe.skip("GenericMediaListPage", () => {
displayComponent: "tracklist" as const,
};
vi.useFakeTimers();
const { container } = render(GenericMediaListPage, {
props: { config },
});
// Wait for the initial mount load so the debounced search effect is armed.
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled());
const searchInput = container.querySelector("input") as HTMLInputElement;
fireEvent.input(searchInput, { target: { value: "test" } });
// Advance timer to trigger debounced search
vi.advanceTimersByTime(300);
// search() is called as search(query, options, requestId).
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
includeItemTypes: ["Audio"],
limit: 10000,
}));
expect(mockSearchFn).toHaveBeenCalledWith(
"test",
expect.objectContaining({
includeItemTypes: ["Audio"],
limit: 10000,
}),
expect.any(Number)
);
});
vi.useRealTimers();
});
it("should use getItems without search for empty query", async () => {
@@ -416,15 +453,18 @@ describe.skip("GenericMediaListPage", () => {
});
it("should include correct itemType in search request", async () => {
vi.useFakeTimers();
const mockSearchFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: vi.fn(),
getItems: mockGetItemsFn,
search: mockSearchFn,
};
@@ -446,17 +486,20 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled());
const searchInput = container.querySelector("input") as HTMLInputElement;
fireEvent.input(searchInput, { target: { value: "album" } });
vi.advanceTimersByTime(300);
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("album", expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}));
expect(mockSearchFn).toHaveBeenCalledWith(
"album",
expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}),
expect.any(Number)
);
});
vi.useRealTimers();
});
});
@@ -536,6 +579,7 @@ describe.skip("GenericMediaListPage", () => {
it("should handle missing library gracefully", async () => {
const { goto } = await import("$app/navigation");
vi.mocked(goto).mockClear();
const mockGetItemsFn = vi.fn();
@@ -548,14 +592,12 @@ describe.skip("GenericMediaListPage", () => {
mockRepository as any
);
// Mock currentLibrary to return null
vi.resetModules();
vi.mocked((await import("$lib/stores/library")).currentLibrary.subscribe).mockImplementation(
(fn: any) => {
fn(null);
return vi.fn();
}
);
// Deliver a null current library for this test only.
const currentLibrary = vi.mocked((await import("$lib/stores/library")).currentLibrary);
currentLibrary.subscribe.mockImplementation((fn: any) => {
fn(null);
return vi.fn();
});
const config = {
itemType: "Audio" as const,
@@ -571,9 +613,15 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
// Should navigate to back path when library is missing
await waitFor(() => {
// goto would be called with config.backPath
// With no current library, loadItems bails out to the back path and never
// queries the repository.
await waitFor(() => expect(goto).toHaveBeenCalledWith("/library/music"));
expect(mockGetItemsFn).not.toHaveBeenCalled();
// Restore the default (non-null) library for subsequent tests.
currentLibrary.subscribe.mockImplementation((fn: any) => {
fn({ id: "lib123", name: "Music" });
return vi.fn();
});
});
});
@@ -1,373 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/svelte";
import MediaCard from "./MediaCard.svelte";
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: vi.fn(() => ({
getImageUrl: vi.fn(),
})),
},
}));
describe.skip("MediaCard - Async Image Loading", () => {
// Component rendering tests skipped - core async logic tested in repository-client.test.ts
let mockRepository: any;
beforeEach(() => {
vi.clearAllMocks();
mockRepository = {
getImageUrl: vi.fn(),
};
vi.mocked((global as any).__stores_auth?.auth?.getRepository).mockReturnValue(mockRepository);
});
afterEach(() => {
vi.clearAllTimers();
});
describe("Image Loading", () => {
it("should load image URL asynchronously", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Component should render immediately with placeholder
expect(container).toBeTruthy();
// Wait for image URL to load
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
expect.objectContaining({
maxWidth: 300,
})
);
});
});
it("should show placeholder while image is loading", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve(mockImageUrl), 100))
);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Placeholder should be visible initially
const placeholder = container.querySelector(".placeholder");
if (placeholder) {
expect(placeholder).toBeTruthy();
}
// Wait for image to load
vi.useFakeTimers();
vi.advanceTimersByTime(100);
vi.useRealTimers();
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
});
it("should update image URL when item changes", async () => {
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl1);
const mediaItem1 = {
id: "item1",
name: "Album 1",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "tag1",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem1 },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item1", "Primary", expect.any(Object));
});
// Change item
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl2);
const mediaItem2 = {
id: "item2",
name: "Album 2",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "tag2",
};
await rerender({ item: mediaItem2 });
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item2", "Primary", expect.any(Object));
});
});
it("should not reload image if item ID hasn't changed", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
// Rerender with same item
await rerender({ item: mediaItem });
// Should not call getImageUrl again
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
it("should handle missing primary image tag gracefully", async () => {
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
// primaryImageTag is undefined
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
// Should render without calling getImageUrl
await waitFor(() => {
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
});
// Should show placeholder
expect(container).toBeTruthy();
});
it("should handle image load errors gracefully", async () => {
mockRepository.getImageUrl.mockRejectedValue(new Error("Failed to load image"));
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
const { container } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
// Should still render without crashing
expect(container).toBeTruthy();
});
});
describe("Image Options", () => {
it("should pass correct options to getImageUrl", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
});
it("should include tag in image options when available", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "tag123",
};
render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
"item123",
"Primary",
{
maxWidth: 300,
}
);
});
});
});
describe("Caching", () => {
it("should cache image URLs to avoid duplicate requests", async () => {
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
// Render same item multiple times
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
// Rerender with same item
await rerender({ item: mediaItem });
// Should still only have called once (cached)
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
it("should have separate cache entries for different items", async () => {
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
let callCount = 0;
mockRepository.getImageUrl.mockImplementation(() => {
callCount++;
return Promise.resolve(callCount === 1 ? mockImageUrl1 : mockImageUrl2);
});
const item1 = {
id: "item1",
name: "Album 1",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "tag1",
};
const item2 = {
id: "item2",
name: "Album 2",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "tag2",
};
const { rerender } = render(MediaCard, {
props: { item: item1 },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
});
await rerender({ item: item2 });
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
// Change back to item 1 - should use cached value
await rerender({ item: item1 });
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
});
});
describe("Reactive Updates", () => {
it("should respond to property changes via $effect", async () => {
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
const mediaItem = {
id: "item123",
name: "Test Album",
type: "MusicAlbum" as const,
serverId: "server-1",
primaryImageTag: "abc123",
};
const { rerender } = render(MediaCard, {
props: { item: mediaItem },
});
await waitFor(() => {
expect(mockRepository.getImageUrl).toHaveBeenCalled();
});
const previousCallCount = mockRepository.getImageUrl.mock.calls.length;
// Update a property that shouldn't trigger reload
await rerender({
item: {
...mediaItem,
name: "Updated Album Name",
},
});
// Should not call getImageUrl again (same primaryImageTag)
expect(mockRepository.getImageUrl.mock.calls.length).toBe(previousCallCount);
});
});
});
+36 -43
View File
@@ -54,8 +54,9 @@ import { invoke } from "@tauri-apps/api/core";
import TrackList from "./TrackList.svelte";
import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast";
describe.skip("TrackList", () => {
describe("TrackList", () => {
const mockRepository = {
getAudioStreamUrl: vi.fn(),
getImageUrl: vi.fn(),
@@ -118,7 +119,8 @@ describe.skip("TrackList", () => {
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
expect(getAllByText("Song 2").length).toBeGreaterThan(0);
expect(getAllByText(/Song 3 with a Very Long Name/).length).toBeGreaterThan(0);
// Long names are abbreviated in the middle via truncateMiddle(name, 48).
expect(getAllByText(/Song 3 with a Very Long .*Should Be Truncated/).length).toBeGreaterThan(0);
});
it("shows loading skeleton when loading=true", () => {
@@ -137,10 +139,12 @@ describe.skip("TrackList", () => {
});
it("shows artist column by default", () => {
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
const { getByText, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
// Header only exists in the desktop table.
expect(getByText("Artist")).toBeTruthy();
expect(getByText("Artist 1")).toBeTruthy();
// Artist name renders in both desktop and mobile views.
expect(getAllByText("Artist 1").length).toBeGreaterThan(0);
});
it("hides artist column when showArtist=false", () => {
@@ -153,10 +157,12 @@ describe.skip("TrackList", () => {
});
it("shows album column by default", () => {
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
const { getByText, getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
// Header only exists in the desktop table.
expect(getByText("Album")).toBeTruthy();
expect(getByText("Album 1")).toBeTruthy();
// Album name renders in both desktop and mobile views.
expect(getAllByText("Album 1").length).toBeGreaterThan(0);
});
it("hides album column when showAlbum=false", () => {
@@ -185,12 +191,13 @@ describe.skip("TrackList", () => {
},
];
// Component renders both desktop and mobile views
// formatDuration(undefined) renders an empty string, so the row still
// renders without crashing and the track title is present.
const { getAllByText } = render(TrackList, {
props: { tracks: tracksWithoutDuration },
});
expect(getAllByText("-").length).toBeGreaterThan(0);
expect(getAllByText("Song 1").length).toBeGreaterThan(0);
});
it("handles tracks without artist", () => {
@@ -198,20 +205,24 @@ describe.skip("TrackList", () => {
{
...mockTracks[0],
artists: undefined,
artistItems: undefined,
},
];
const { getByText } = render(TrackList, {
// The artist fallback renders "-" in both desktop and mobile views.
const { getAllByText } = render(TrackList, {
props: { tracks: tracksWithoutArtist },
});
expect(getByText("-")).toBeTruthy();
expect(getAllByText("-").length).toBeGreaterThan(0);
});
it("renders multiple artists joined with comma", () => {
const { getByText } = render(TrackList, { props: { tracks: mockTracks } });
// Tracks fall back to artists.join(", ") when artistItems is absent;
// the joined string renders in both desktop and mobile views.
const { getAllByText } = render(TrackList, { props: { tracks: mockTracks } });
expect(getByText("Artist 3, Artist 4")).toBeTruthy();
expect(getAllByText("Artist 3, Artist 4").length).toBeGreaterThan(0);
});
});
@@ -278,25 +289,8 @@ describe.skip("TrackList", () => {
});
});
it.skip("calls getAudioStreamUrl for each track", async () => {
// NOTE: This test is skipped because the code was refactored to use player_play_tracks
// which sends trackIds to the backend. The backend now handles all metadata/stream fetching.
// This test expected the old behavior where frontend called getAudioStreamUrl.
});
it.skip("includes artwork URLs in queue items", async () => {
// NOTE: This test is skipped because the code was refactored.
// Stream URLs and artwork URLs are no longer fetched by frontend.
// Backend handles all metadata and stream URL fetching via player_play_tracks.
});
it.skip("handles tracks without artwork gracefully", async () => {
// NOTE: This test is skipped because the code no longer includes artwork URLs
// in queue items sent to backend. Backend handles artwork fetching independently.
});
it("shows error alert when playback fails", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
it("shows error toast when playback fails", async () => {
const toastSpy = vi.spyOn(toast, "error").mockImplementation(() => "");
(invoke as any).mockRejectedValue(new Error("Network error"));
const { container } = render(TrackList, { props: { tracks: mockTracks } });
@@ -309,16 +303,19 @@ describe.skip("TrackList", () => {
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track")
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
);
});
alertSpy.mockRestore();
toastSpy.mockRestore();
});
it("handles auth errors gracefully", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => {});
const toastSpy = vi.spyOn(toast, "error").mockImplementation(() => "");
// No repository → requireHandle() throws "No repository available",
// which the default handler surfaces via toast.error.
(auth.getRepository as any).mockReturnValue(null as any);
const { container } = render(TrackList, { props: { tracks: mockTracks } });
@@ -331,22 +328,18 @@ describe.skip("TrackList", () => {
await fireEvent.click(firstTrackButton!);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith(
expect.stringContaining("Not authenticated")
expect(toastSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to play track"),
expect.anything()
);
});
alertSpy.mockRestore();
toastSpy.mockRestore();
// Restore mock for other tests
(auth.getRepository as any).mockReturnValue(mockRepository as any);
});
it.skip("handles stream URL generation errors", async () => {
// NOTE: This test is skipped because stream URLs are no longer fetched by frontend.
// The code now uses player_play_tracks which sends trackIds to backend.
// Backend handles all stream URL generation, so this error path no longer exists.
});
});
describe("Custom Callback Tests", () => {
+250 -3
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
<script lang="ts">
import { onMount, onDestroy, untrack } from "svelte";
import { goto } from "$app/navigation";
@@ -18,6 +18,20 @@
import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
isBackgroundAudioSupported,
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
subscribeAppForegrounded,
} from "$lib/utils/backgroundAudio";
import {
computeHandoffPosition,
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
interface Props {
media: MediaItem | null;
@@ -488,6 +502,15 @@
// Set up progress reporting interval
onMount(async () => {
// Background-audio lifecycle listeners MUST be registered synchronously —
// before any await below — per the native-mode pitfall (an await here can
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
// so onDestroy tears them down.
if (backgroundAudioSupported) {
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
}
// Initialize player via Rust - Rust will decide which backend to use based on platform
if (media && currentStreamUrl) {
try {
@@ -680,12 +703,19 @@
clearInterval(debugLogInterval);
}
// Remove native backend event listeners
// Remove native backend event listeners (incl. background-audio lifecycle subs)
for (const unlisten of nativeUnlisteners) {
unlisten();
}
nativeUnlisteners = [];
// Re-assert defaults so this player's background-audio choice can't leak into
// the next one: disarm background audio and restore auto-PiP.
if (backgroundAudioSupported) {
setBackgroundAudioEnabled(false);
setAutoEnterEnabled(true);
}
// Clean up HLS.js instance - prevent dual audio on unmount
if (hls) {
console.log("[VideoPlayer] Destroying HLS.js instance on unmount");
@@ -796,10 +826,55 @@
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
// canplay-fallback timeout was never armed — audio played while the video
// stayed invisible. Any of these callers now reveals it.
// Apply the pending background-audio foreground seek, if any. This MUST run
// no matter which readiness signal fired — on the Android WebView HLS/MSE path
// `canplay` is unreliable and the video is revealed via markMediaReady()
// instead, so gating this on handleCanPlay alone meant the seek was silently
// dropped and the reloaded stream played from its start (resume "started from
// the beginning"). Returns true if a pending seek was consumed.
async function applyPendingForegroundSeek(): Promise<boolean> {
if (pendingForegroundSeek === null || !videoElement) return false;
const seekTo = pendingForegroundSeek;
const shouldPlay = pendingForegroundPlay;
pendingForegroundSeek = null;
pendingForegroundPlay = false;
hasPerformedInitialSeek = true;
const el = videoElement;
// currentTime is only honored once the element has metadata (duration/seekable).
// If it isn't there yet, defer to loadedmetadata rather than seeking into a
// still-empty timeline (which the element clamps back to 0).
const doSeek = async () => {
try {
el.currentTime = seekTo;
// Displayed position is absolute: element time + transcode seekOffset.
// (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos,
// seekTo=0.) Both yield the correct absolute position.
currentTime = seekOffset + seekTo;
el.muted = false;
el.volume = 1.0;
if (shouldPlay) await el.play();
} catch (err) {
console.error("[VideoPlayer] Failed to resume after background audio:", err);
}
};
if (el.readyState >= 1 /* HAVE_METADATA */) {
console.log("[VideoPlayer] Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
await doSeek();
} else {
console.log("[VideoPlayer] Deferring foreground seek until loadedmetadata:", (seekOffset + seekTo).toFixed(1));
el.addEventListener("loadedmetadata", () => { void doSeek(); }, { once: true });
}
return true;
}
function markMediaReady() {
if (isMediaReady) return;
console.log("[VideoPlayer] Marking media ready");
isMediaReady = true;
// A handoff return can be revealed here (not via canplay) — apply its seek.
void applyPendingForegroundSeek();
}
async function handleCanPlay() {
@@ -814,6 +889,13 @@
console.log("[VideoPlayer] Video unmuted on canplay, volume: 1.0");
}
// Returning from background audio: resume the <video> at the position native
// audio reached, restoring the prior play/pause state. Takes precedence over
// the resume-point seek below (which is for a fresh load, not a handoff).
if (await applyPendingForegroundSeek()) {
return;
}
// Seek to initial position if resuming playback
if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
console.log("[VideoPlayer] Seeking to initial position:", initialPosition);
@@ -923,7 +1005,7 @@
// Check if video is actually ready despite event not firing
if (videoElement.readyState >= 3) { // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
console.log("[VideoPlayer] Video appears ready (readyState >= 3), forcing media ready state");
isMediaReady = true;
markMediaReady();
}
}
}, 5000);
@@ -1081,6 +1163,143 @@
}
}
// Resolved once at component setup: the PiP bridge is installed by
// MainActivity before the page loads and never changes for the session.
// Synchronous by design - no await in onMount (see VideoPlayer native-mode
// pitfalls: awaiting there flips the component into HTML5 mode).
const pipSupported = isPipSupported();
function handlePictureInPicture() {
enterPip();
}
// ===== Background audio (UR-040, Android) =====
// Keep the video's audio playing when the app is backgrounded/locked by handing
// playback off to the native ExoPlayer audio service; the WebView <video> is
// torn down so no video is decoded. Mutually exclusive with auto-PiP.
//
// Resolved synchronously (no await) for the same native-mode reason as PiP.
const backgroundAudioSupported = isBackgroundAudioSupported();
let backgroundAudioOn = $state(false); // v1: default OFF each session
let handoffState: BackgroundAudioState = { ...initialHandoffState };
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// so exactly one background behavior is active.
setBackgroundAudioEnabled(backgroundAudioOn);
setAutoEnterEnabled(!backgroundAudioOn);
}
// App went to background/locked while background-audio is armed: hand off to
// native audio and stop the WebView video decode.
async function enterBackgroundAudioHandoff() {
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
// loop keeps it at seekOffset + element.currentTime, and it survives HLS
// transcode segment resets). Reading videoElement.currentTime directly is
// wrong for transcoded streams (it's the in-segment offset) and can read 0
// if the element is mid-teardown — which shipped audio starting from 0:00.
const pos = computeHandoffPosition(currentTime, 0);
const wasPlaying = isPlaying;
console.log("[VideoPlayer] Background-audio handoff at position:", pos.toFixed(1));
handoffState = { active: true, wasPlaying };
try {
if (!media) return;
// Ask the server for an audio-only stream of this video item (no video
// decode), carrying the selected audio track and resume position.
const audioUrl = await auth.getRepository().getAudioOnlyStreamUrlForVideo(
media.id,
mediaSourceId ?? undefined,
pos,
selectedAudioTrackIndex ?? undefined,
);
await commands.playerEnterBackgroundAudio(
{
id: media.id,
title: media.name,
streamUrl: audioUrl,
videoCodec: "aac",
needsTranscoding: false,
// Now-playing metadata so the lockscreen/miniplayer show the item.
artist: media.seriesName ?? null,
primaryImageTag: media.primaryImageTag ?? null,
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
},
pos,
);
// Tear down the WebView <video>/HLS decode AFTER native audio has started,
// so there is never a gap — and exactly one audio source is ever live.
tearDownHls();
if (videoElement) {
videoElement.pause();
videoElement.removeAttribute("src");
videoElement.load();
}
} catch (err) {
console.error("[VideoPlayer] Background-audio handoff failed:", err);
handoffState = { ...initialHandoffState };
}
}
// App returned to foreground: stop native audio, reload the WebView <video> at
// the position native reached, and restore play/pause.
async function exitBackgroundAudioHandoff() {
if (!shouldExitBackgroundAudio(handoffState)) return;
const wasPlaying = handoffState.wasPlaying;
handoffState = { ...initialHandoffState };
try {
// Absolute position the native audio reached (base offset applied in Rust).
const pos = await commands.playerExitBackgroundAudio();
console.log("[VideoPlayer] Returning from background audio at:", pos.toFixed(1));
isMediaReady = false;
// The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
// post-handoff position. Keep the initial-position change-effect quiescent:
// leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
// to the current prop means the effect sees no "change" and won't fire a
// stale seek back to the original resume point (clobbering the handoff pos).
hasPerformedInitialSeek = true;
lastAppliedInitialPosition = initialPosition;
pendingForegroundPlay = wasPlaying;
// Determine the target URL + how the element/offset should be positioned.
let targetUrl: string;
if (needsTranscoding && onSeek) {
// Transcoded HLS can't seek by setting currentTime — the stream must be
// rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
// The reloaded segment's timeline starts at 0, so seekOffset carries the
// absolute base and the element seeks to 0 (handled on canplay).
targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
seekOffset = pos;
currentTime = pos;
pendingForegroundSeek = 0;
} else {
// Direct stream: reload the original URL and seek the element to pos.
targetUrl = streamUrl;
seekOffset = 0;
pendingForegroundSeek = pos;
}
// Force the HLS-init $effect to re-run even if the URL string is unchanged:
// blank it first, then set it on the next microtask so Svelte sees a real
// transition. Without this, assigning the same value is a no-op and the
// player stays stuck on the loading spinner (HLS never re-initialises).
currentStreamUrl = "";
await Promise.resolve();
currentStreamUrl = targetUrl;
} catch (err) {
console.error("[VideoPlayer] Background-audio return failed:", err);
}
}
// Consumed by handleCanPlay after the <video> reloads on foreground.
let pendingForegroundSeek: number | null = null;
let pendingForegroundPlay = false;
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
@@ -1721,6 +1940,34 @@
<!-- Volume Control -->
<VolumeControl size="md" />
<!-- Picture-in-picture (Android only) -->
{#if pipSupported}
<button
onclick={handlePictureInPicture}
class="text-white hover:text-gray-300"
aria-label="Picture in picture"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" />
</svg>
</button>
{/if}
<!-- Background audio (Android only) — keep audio playing when the app is
backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
{#if backgroundAudioSupported}
<button
onclick={toggleBackgroundAudio}
class={backgroundAudioOn ? "text-blue-400 hover:text-blue-300" : "text-white hover:text-gray-300"}
aria-label="Background audio"
aria-pressed={backgroundAudioOn}
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z" />
</svg>
</button>
{/if}
<!-- Fullscreen -->
<button onclick={toggleFullscreen} class="text-white hover:text-gray-300" aria-label="Toggle fullscreen">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import {
computeHandoffPosition,
initialHandoffState,
shouldEnterBackgroundAudio,
shouldExitBackgroundAudio,
type BackgroundAudioState,
} from "./backgroundAudioHandoff";
// TRACES: UR-040 | DR-052 | UT-060
describe("backgroundAudioHandoff", () => {
describe("computeHandoffPosition", () => {
it("sums element time and transcode seekOffset (absolute position)", () => {
// Transcoded HLS resets element time to 0 after a reload; seekOffset carries
// the cumulative offset. The audio stream must resume at the absolute pos.
expect(computeHandoffPosition(12, 180)).toBe(192);
});
it("handles a direct stream with no offset", () => {
expect(computeHandoffPosition(45, 0)).toBe(45);
});
it("never returns a negative position", () => {
expect(computeHandoffPosition(-5, 0)).toBe(0);
});
});
describe("shouldEnterBackgroundAudio", () => {
it("enters when toggle is on and not already handed off", () => {
expect(shouldEnterBackgroundAudio(true, initialHandoffState)).toBe(true);
});
it("does not enter when the toggle is off", () => {
expect(shouldEnterBackgroundAudio(false, initialHandoffState)).toBe(false);
});
it("does not double-enter when already active", () => {
const active: BackgroundAudioState = { active: true, wasPlaying: true };
expect(shouldEnterBackgroundAudio(true, active)).toBe(false);
});
});
describe("shouldExitBackgroundAudio", () => {
it("exits when a handoff is active", () => {
const active: BackgroundAudioState = { active: true, wasPlaying: false };
expect(shouldExitBackgroundAudio(active)).toBe(true);
});
it("does not exit when no handoff happened", () => {
expect(shouldExitBackgroundAudio(initialHandoffState)).toBe(false);
});
it("exits even if the toggle was turned off while backgrounded", () => {
// shouldExit ignores the toggle by design, so turning it off mid-background
// still returns cleanly to video on foreground.
const active: BackgroundAudioState = { active: true, wasPlaying: true };
expect(shouldExitBackgroundAudio(active)).toBe(true);
});
});
});
@@ -0,0 +1,57 @@
/**
* Pure helpers for the video background-audio handoff (UR-040).
*
* TRACES: UR-040 | DR-052 | UT-060
*
* Kept free of Svelte/DOM so the handoff arithmetic and state transitions are
* unit-testable without mounting the player. The component
* (VideoPlayer.svelte) owns the actual `<video>` teardown and IPC calls.
*/
/**
* Absolute playback position to resume the audio stream at.
*
* Transcoded HLS playback tracks time as `videoElement.currentTime + seekOffset`
* (the element resets to 0 after each transcode reload; `seekOffset` carries the
* cumulative offset). Background audio must resume at that ABSOLUTE position, so
* both terms are summed here mirroring the `effectiveTime` used elsewhere in
* the player.
*/
export function computeHandoffPosition(elementCurrentTime: number, seekOffset: number): number {
const pos = elementCurrentTime + seekOffset;
return pos > 0 ? pos : 0;
}
/**
* The handoff state. `wasPlaying` is captured on the way out so play/pause is
* restored when the app returns to the foreground.
*/
export interface BackgroundAudioState {
active: boolean;
wasPlaying: boolean;
}
export const initialHandoffState: BackgroundAudioState = {
active: false,
wasPlaying: false,
};
/**
* Whether a background signal should trigger the audio handoff right now.
* Only when the toggle is on and we're not already handed off.
*/
export function shouldEnterBackgroundAudio(
toggleOn: boolean,
state: BackgroundAudioState
): boolean {
return toggleOn && !state.active;
}
/**
* Whether a foreground signal should trigger the return to WebView video.
* Only when we actually handed off (regardless of the current toggle value, so
* turning the toggle off while backgrounded still returns cleanly).
*/
export function shouldExitBackgroundAudio(state: BackgroundAudioState): boolean {
return state.active;
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
vi.mock("@tauri-apps/api/core");
// TRACES: UR-040 | DR-052 | UT-061
//
// Guards the Tauri v2 camelCase param rule for the background-audio commands:
// the command NAME stays snake_case; params are camelCase.
describe("background-audio player commands (param naming)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("player_enter_background_audio sends item + positionSeconds (camelCase)", async () => {
(invoke as any).mockResolvedValueOnce({});
const item = {
id: "vid-1",
title: "Episode 1",
streamUrl: "https://server/Audio/vid-1/universal?AudioStreamIndex=1",
videoCodec: "aac",
needsTranscoding: false,
};
await commands.playerEnterBackgroundAudio(item, 193);
expect(invoke).toHaveBeenCalledWith("player_enter_background_audio", {
item,
positionSeconds: 193,
});
});
it("player_exit_background_audio takes no params and returns a position", async () => {
(invoke as any).mockResolvedValueOnce(193.5);
const pos = await commands.playerExitBackgroundAudio();
expect(pos).toBe(193.5);
expect(invoke).toHaveBeenCalledWith("player_exit_background_audio");
});
});

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