android-picture-in-picture #12
@@ -0,0 +1,125 @@
|
||||
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
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- 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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+45
-10
@@ -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 (§9.1)]
|
||||
|
||||
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 <video> 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/> (reflects live player state)]
|
||||
|
||||
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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.15",
|
||||
"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": {
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -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
@@ -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_fast::<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))?;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +31,8 @@ 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 {
|
||||
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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -208,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;
|
||||
@@ -240,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;
|
||||
}
|
||||
}
|
||||
@@ -309,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(
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -341,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 })
|
||||
}
|
||||
@@ -384,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())],
|
||||
@@ -411,7 +443,8 @@ 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 {
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
@@ -455,7 +488,8 @@ 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 {
|
||||
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}"))
|
||||
})
|
||||
|
||||
@@ -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>>);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use playback_mode::*;
|
||||
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::*;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
@@ -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,7 +201,10 @@ 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(),
|
||||
@@ -200,11 +216,15 @@ pub async fn player_add_track_by_id(
|
||||
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 {
|
||||
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,7 +319,10 @@ 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(),
|
||||
@@ -302,11 +334,15 @@ pub async fn player_add_tracks_by_ids(
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,7 +83,12 @@ 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)
|
||||
let online = OnlineRepository::new(
|
||||
Arc::new(http_client),
|
||||
server_url,
|
||||
user_id.clone(),
|
||||
access_token,
|
||||
)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
debug!("[REPO] Online repository created");
|
||||
|
||||
@@ -151,9 +158,7 @@ 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| {
|
||||
repo.as_ref().get_libraries().await.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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,10 +482,11 @@ 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| {
|
||||
let result = db_service
|
||||
.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -460,7 +494,9 @@ pub async fn storage_get_active_session(
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
})
|
||||
.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::*;
|
||||
|
||||
@@ -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,7 +130,8 @@ pub async fn storage_save_item_people(
|
||||
let associations_clone = associations.clone();
|
||||
|
||||
// Use transaction for batch insert
|
||||
db_service.transaction(move |tx| {
|
||||
db_service
|
||||
.transaction(move |tx| {
|
||||
for assoc in &associations_clone {
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO item_people (
|
||||
@@ -128,14 +142,20 @@ pub async fn storage_save_item_people(
|
||||
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),
|
||||
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())?;
|
||||
})
|
||||
.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")]
|
||||
|
||||
@@ -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(
|
||||
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))?;
|
||||
)
|
||||
.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,10 +242,14 @@ 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(
|
||||
let cached_path = thumbnail_cache
|
||||
.0
|
||||
.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
@@ -240,7 +257,8 @@ pub async fn image_get_url(
|
||||
&image_data,
|
||||
request.max_width.map(|w| w as i32),
|
||||
request.max_height.map(|h| h as i32),
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+100
-46
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
@@ -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::*;
|
||||
|
||||
@@ -139,10 +139,7 @@ impl HttpClient {
|
||||
}
|
||||
|
||||
/// 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,31 +189,6 @@ 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 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
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
response.json::<T>().await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||
}
|
||||
|
||||
/// 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:
|
||||
@@ -258,17 +230,17 @@ impl HttpClient {
|
||||
|
||||
/// 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(req) => match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
+316
-116
@@ -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, set_show_server_catalog, 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,
|
||||
@@ -656,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,
|
||||
@@ -722,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
|
||||
@@ -969,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") {
|
||||
@@ -1055,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`.
|
||||
@@ -1063,7 +1262,8 @@ 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");
|
||||
|
||||
@@ -127,7 +127,10 @@ impl PlaybackModeManager {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
@@ -154,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() {
|
||||
@@ -166,15 +177,15 @@ 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| {
|
||||
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)
|
||||
})?;
|
||||
@@ -196,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 {
|
||||
@@ -209,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;
|
||||
@@ -236,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(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,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);
|
||||
@@ -283,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
|
||||
@@ -307,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)?;
|
||||
@@ -372,10 +411,7 @@ 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| {
|
||||
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)
|
||||
@@ -405,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",
|
||||
@@ -413,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 {})",
|
||||
@@ -430,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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,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,
|
||||
@@ -448,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,
|
||||
@@ -540,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
|
||||
}
|
||||
}
|
||||
@@ -572,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,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)
|
||||
{
|
||||
@@ -677,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
|
||||
}
|
||||
@@ -843,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);
|
||||
@@ -931,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(
|
||||
|
||||
@@ -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;
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
..
|
||||
} => {
|
||||
client
|
||||
.report_playback_start(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
|
||||
client.report_playback_progress(
|
||||
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);
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
|
||||
item_id,
|
||||
is_paused
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
client.report_playback_stopped(
|
||||
PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
client
|
||||
.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
)
|
||||
.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;
|
||||
|
||||
|
||||
@@ -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,12 +1078,7 @@ fn start_playback_service() -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Call startPlaybackService() on the player instance
|
||||
env.call_method(
|
||||
&player_obj,
|
||||
"startPlaybackService",
|
||||
"()V",
|
||||
&[],
|
||||
)
|
||||
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
|
||||
log::info!("[Android] JellyTauPlaybackService start requested");
|
||||
@@ -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,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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+412
-89
@@ -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(¤t).await;
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).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,7 +1002,10 @@ 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 {
|
||||
@@ -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 ¤t_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!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +129,7 @@ impl MpvBackend {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
+472
-162
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
///
|
||||
|
||||
+406
-110
@@ -1,11 +1,11 @@
|
||||
// Offline repository - queries SQLite database for cached data
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::debug;
|
||||
|
||||
use super::{MediaRepository, types::*};
|
||||
use super::{types::*, MediaRepository};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
|
||||
|
||||
/// Whether offline library queries may include catalog items that are merely
|
||||
@@ -40,12 +40,17 @@ pub struct OfflineRepository {
|
||||
|
||||
impl OfflineRepository {
|
||||
pub fn new(db_service: Arc<RusqliteService>, server_id: String, user_id: String) -> Self {
|
||||
Self { db_service, server_id, user_id }
|
||||
Self {
|
||||
db_service,
|
||||
server_id,
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert CachedItem from storage to MediaItem
|
||||
fn cached_item_to_media_item(item: CachedItem, user_data: Option<UserData>) -> MediaItem {
|
||||
let artists_vec = item.artists
|
||||
let artists_vec = item
|
||||
.artists
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.unwrap_or_default();
|
||||
@@ -59,7 +64,8 @@ impl OfflineRepository {
|
||||
parent_id: item.parent_id,
|
||||
library_id: item.library_id,
|
||||
overview: item.overview,
|
||||
genres: item.genres
|
||||
genres: item
|
||||
.genres
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
|
||||
runtime_ticks: item.runtime_ticks,
|
||||
@@ -99,7 +105,8 @@ impl OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.query_optional(query, |row| {
|
||||
self.db_service
|
||||
.query_optional(query, |row| {
|
||||
Ok(UserData {
|
||||
playback_position_ticks: row.get(0).ok(),
|
||||
is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
|
||||
@@ -109,7 +116,10 @@ impl OfflineRepository {
|
||||
playback_context_type: row.get(5).ok(),
|
||||
playback_context_id: row.get(6).ok(),
|
||||
})
|
||||
}).await.ok().flatten()
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +206,19 @@ impl OfflineRepository {
|
||||
|
||||
// Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
|
||||
// when replacing stub parent items with their actual data
|
||||
self.db_service.execute(Query::new("PRAGMA foreign_keys = OFF")).await
|
||||
self.db_service
|
||||
.execute(Query::new("PRAGMA foreign_keys = OFF"))
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
// Ensure we re-enable foreign keys even if an error occurs
|
||||
let result = self.save_to_cache_impl(parent_id, items, &now).await;
|
||||
|
||||
// Re-enable foreign key constraints
|
||||
let _ = self.db_service.execute(Query::new("PRAGMA foreign_keys = ON")).await;
|
||||
let _ = self
|
||||
.db_service
|
||||
.execute(Query::new("PRAGMA foreign_keys = ON"))
|
||||
.await;
|
||||
|
||||
result
|
||||
}
|
||||
@@ -241,21 +256,33 @@ impl OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let _stub_rows = self.db_service.execute(parent_query).await
|
||||
let _stub_rows = self
|
||||
.db_service
|
||||
.execute(parent_query)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
#[cfg(test)]
|
||||
println!(" Created stub parent {} (rows affected: {})", pid, _stub_rows);
|
||||
println!(
|
||||
" Created stub parent {} (rows affected: {})",
|
||||
pid, _stub_rows
|
||||
);
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
|
||||
for item in items {
|
||||
// Convert Option<Vec<String>> to JSON strings for storage
|
||||
let genres_json = item.genres.as_ref()
|
||||
let genres_json = item
|
||||
.genres
|
||||
.as_ref()
|
||||
.map(|g| serde_json::to_string(g).unwrap_or_else(|_| "[]".to_string()));
|
||||
let artists_json = item.artists.as_ref()
|
||||
let artists_json = item
|
||||
.artists
|
||||
.as_ref()
|
||||
.map(|a| serde_json::to_string(a).unwrap_or_else(|_| "[]".to_string()));
|
||||
let backdrop_tags_json = item.backdrop_image_tags.as_ref()
|
||||
let backdrop_tags_json = item
|
||||
.backdrop_image_tags
|
||||
.as_ref()
|
||||
.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
|
||||
|
||||
// Use INSERT OR REPLACE to upsert items
|
||||
@@ -374,10 +401,18 @@ impl OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let _rows_affected = self.db_service.execute(query).await
|
||||
.map_err(|e| RepoError::Database { message: format!("Failed to insert item {}: {}", item.id, e) })?;
|
||||
let _rows_affected =
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to insert item {}: {}", item.id, e),
|
||||
})?;
|
||||
#[cfg(test)]
|
||||
println!(" [save_to_cache] Saved item {} (rows affected: {})", item.id, _rows_affected);
|
||||
println!(
|
||||
" [save_to_cache] Saved item {} (rows affected: {})",
|
||||
item.id, _rows_affected
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
@@ -407,7 +442,9 @@ impl OfflineRepository {
|
||||
QueryParam::Int(idx as i32),
|
||||
],
|
||||
);
|
||||
self.db_service.execute(query).await
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
count += 1;
|
||||
}
|
||||
@@ -541,18 +578,30 @@ impl MediaRepository for OfflineRepository {
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
|
||||
self.db_service.query_many(query, |row| {
|
||||
self.db_service
|
||||
.query_many(query, |row| {
|
||||
Ok(Library {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
collection_type: row.get::<_, Option<String>>(2)?.unwrap_or_else(|| "unknown".to_string()),
|
||||
collection_type: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: row.get(3)?,
|
||||
})
|
||||
}).await.map_err(|e| RepoError::Database { message: e })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })
|
||||
}
|
||||
|
||||
async fn get_items(&self, parent_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
debug!("[OfflineRepo] get_items called for parent_id: {}", &parent_id[..8.min(parent_id.len())]);
|
||||
async fn get_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
debug!(
|
||||
"[OfflineRepo] get_items called for parent_id: {}",
|
||||
&parent_id[..8.min(parent_id.len())]
|
||||
);
|
||||
let opts = options.unwrap_or_default();
|
||||
let limit = opts.limit.unwrap_or(10000); // Match frontend limit for full library loading
|
||||
let start_index = opts.start_index.unwrap_or(0);
|
||||
@@ -660,11 +709,17 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
debug!("[OfflineRepo] Found {} cached items for parent {}", cached_items.len(), &parent_id[..8.min(parent_id.len())]);
|
||||
debug!(
|
||||
"[OfflineRepo] Found {} cached items for parent {}",
|
||||
cached_items.len(),
|
||||
&parent_id[..8.min(parent_id.len())]
|
||||
);
|
||||
|
||||
// Fetch user data for each item
|
||||
let mut items = Vec::new();
|
||||
@@ -675,7 +730,11 @@ impl MediaRepository for OfflineRepository {
|
||||
|
||||
let total_record_count = items.len();
|
||||
|
||||
debug!("[OfflineRepo] Returning {} items for parent {}", total_record_count, &parent_id[..8.min(parent_id.len())]);
|
||||
debug!(
|
||||
"[OfflineRepo] Returning {} items for parent {}",
|
||||
total_record_count,
|
||||
&parent_id[..8.min(parent_id.len())]
|
||||
);
|
||||
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
@@ -715,18 +774,27 @@ impl MediaRepository for OfflineRepository {
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
);
|
||||
|
||||
let cached = self.db_service.query_optional(query, row_to_cached_item)
|
||||
let cached = self
|
||||
.db_service
|
||||
.query_optional(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?
|
||||
.ok_or_else(|| RepoError::NotFound {
|
||||
message: format!("Item {} not found in offline cache or not downloaded", item_id),
|
||||
message: format!(
|
||||
"Item {} not found in offline cache or not downloaded",
|
||||
item_id
|
||||
),
|
||||
})?;
|
||||
|
||||
let user_data = self.get_user_data(item_id).await;
|
||||
Ok(Self::cached_item_to_media_item(cached, user_data))
|
||||
}
|
||||
|
||||
async fn get_latest_items(&self, parent_id: &str, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
async fn get_latest_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_val = limit.unwrap_or(16);
|
||||
|
||||
let query = Query::with_params(
|
||||
@@ -766,7 +834,9 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -779,7 +849,11 @@ impl MediaRepository for OfflineRepository {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
async fn get_resume_items(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_val = limit.unwrap_or(12);
|
||||
|
||||
// Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
|
||||
@@ -800,13 +874,14 @@ impl MediaRepository for OfflineRepository {
|
||||
AND d.status = 'completed'
|
||||
AND i.item_type IN ('Movie', 'Episode')
|
||||
ORDER BY ud.last_played_at DESC
|
||||
LIMIT {}", limit_val
|
||||
LIMIT {}",
|
||||
limit_val
|
||||
),
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
QueryParam::String(self.user_id.clone()),
|
||||
QueryParam::String(pid.to_string()),
|
||||
]
|
||||
],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
@@ -825,18 +900,21 @@ impl MediaRepository for OfflineRepository {
|
||||
AND d.status = 'completed'
|
||||
AND i.item_type IN ('Movie', 'Episode')
|
||||
ORDER BY ud.last_played_at DESC
|
||||
LIMIT {}", limit_val
|
||||
LIMIT {}",
|
||||
limit_val
|
||||
),
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
QueryParam::String(self.user_id.clone()),
|
||||
]
|
||||
],
|
||||
)
|
||||
};
|
||||
|
||||
let query = Query::with_params(sql, params);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -849,13 +927,20 @@ impl MediaRepository for OfflineRepository {
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn get_next_up_episodes(&self, _series_id: Option<&str>, _limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
async fn get_next_up_episodes(
|
||||
&self,
|
||||
_series_id: Option<&str>,
|
||||
_limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is complex - would need to track watched episodes and find the next unwatched
|
||||
// For now, return empty for offline mode
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||
async fn get_recently_played_audio(
|
||||
&self,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_val = limit.unwrap_or(12);
|
||||
|
||||
// Use CTE to intelligently group by playback context and filter by downloads
|
||||
@@ -917,7 +1002,9 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -960,7 +1047,8 @@ impl MediaRepository for OfflineRepository {
|
||||
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
||||
AND d.status = 'completed'
|
||||
ORDER BY ud.last_played_at DESC
|
||||
LIMIT {}", limit_val
|
||||
LIMIT {}",
|
||||
limit_val
|
||||
),
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
@@ -968,7 +1056,9 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -1011,7 +1101,11 @@ impl MediaRepository for OfflineRepository {
|
||||
Ok(genres)
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str, options: Option<SearchOptions>) -> Result<SearchResult, RepoError> {
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
options: Option<SearchOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let limit = opts.limit.unwrap_or(20);
|
||||
|
||||
@@ -1077,7 +1171,9 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(db_query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(db_query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -1120,22 +1216,39 @@ impl MediaRepository for OfflineRepository {
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn report_playback_start(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
async fn report_playback_start(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_position_ticks: i64,
|
||||
) -> Result<(), RepoError> {
|
||||
// Cannot report to server while offline
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn report_playback_progress(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
async fn report_playback_progress(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_position_ticks: i64,
|
||||
) -> Result<(), RepoError> {
|
||||
// Cannot report to server while offline
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(&self, _item_id: &str, _position_ticks: i64) -> Result<(), RepoError> {
|
||||
async fn report_playback_stopped(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_position_ticks: i64,
|
||||
) -> Result<(), RepoError> {
|
||||
// Cannot report to server while offline
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
fn get_image_url(&self, item_id: &str, image_type: ImageType, options: Option<ImageOptions>) -> String {
|
||||
fn get_image_url(
|
||||
&self,
|
||||
item_id: &str,
|
||||
image_type: ImageType,
|
||||
options: Option<ImageOptions>,
|
||||
) -> String {
|
||||
// Return a placeholder path for offline image retrieval
|
||||
// The actual image should be in thumbnail cache
|
||||
let type_str = match image_type {
|
||||
@@ -1193,7 +1306,9 @@ impl MediaRepository for OfflineRepository {
|
||||
vec![QueryParam::String(person_id.to_string())],
|
||||
);
|
||||
|
||||
let person_data = self.db_service.query_optional(query, |row| {
|
||||
let person_data = self
|
||||
.db_service
|
||||
.query_optional(query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -1243,7 +1358,11 @@ impl MediaRepository for OfflineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_items_by_person(&self, person_id: &str, options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||
async fn get_items_by_person(
|
||||
&self,
|
||||
person_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let limit = opts.limit.unwrap_or(10000); // Match frontend limit
|
||||
|
||||
@@ -1287,7 +1406,9 @@ impl MediaRepository for OfflineRepository {
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self.db_service.query_many(query, row_to_cached_item)
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
@@ -1305,7 +1426,11 @@ impl MediaRepository for OfflineRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||
async fn get_similar_items(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_limit: Option<usize>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
// Similar items require server-side computation and are not available offline
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
@@ -1354,7 +1479,10 @@ impl MediaRepository for OfflineRepository {
|
||||
"DELETE FROM playlists WHERE id = ?",
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to delete playlist: {}", e),
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -1368,16 +1496,16 @@ impl MediaRepository for OfflineRepository {
|
||||
QueryParam::String(playlist_id.to_string()),
|
||||
],
|
||||
);
|
||||
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database {
|
||||
message: format!("Failed to rename playlist: {}", e),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 query = Query::with_params(
|
||||
"SELECT pi.id, \
|
||||
i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
|
||||
@@ -1392,7 +1520,8 @@ impl MediaRepository for OfflineRepository {
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
|
||||
let items = self.db_service
|
||||
let items = self
|
||||
.db_service
|
||||
.query_many(query, |row| {
|
||||
let entry_id: i64 = row.get(0)?;
|
||||
// Columns offset by 1 because first column is pi.id
|
||||
@@ -1451,7 +1580,8 @@ impl MediaRepository for OfflineRepository {
|
||||
"SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
|
||||
vec![QueryParam::String(playlist_id.to_string())],
|
||||
);
|
||||
let max_order: i32 = self.db_service
|
||||
let max_order: i32 = self
|
||||
.db_service
|
||||
.query_one(max_query, |row| row.get(0))
|
||||
.await
|
||||
.unwrap_or(-1);
|
||||
@@ -1498,7 +1628,10 @@ impl MediaRepository for OfflineRepository {
|
||||
for entry_id in &entry_ids {
|
||||
tx.execute(Query::with_params(
|
||||
"DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
|
||||
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())],
|
||||
vec![
|
||||
QueryParam::String(playlist_id.clone()),
|
||||
QueryParam::String(entry_id.clone()),
|
||||
],
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -1576,7 +1709,8 @@ mod tests {
|
||||
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
|
||||
|
||||
// Create minimal schema for testing
|
||||
conn.execute_batch(r#"
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -1673,7 +1807,9 @@ mod tests {
|
||||
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, library_id, name)
|
||||
);
|
||||
"#).unwrap();
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Insert a test server
|
||||
conn.execute(
|
||||
@@ -1903,7 +2039,11 @@ mod tests {
|
||||
// they link by album_id and have a NULL parent_id. This is the call
|
||||
// play_album_track makes to build the queue.
|
||||
let tracks = repo.get_items("album-1", None).await.unwrap();
|
||||
assert_eq!(tracks.items.len(), 1, "get_items(album_id) should return the track");
|
||||
assert_eq!(
|
||||
tracks.items.len(),
|
||||
1,
|
||||
"get_items(album_id) should return the track"
|
||||
);
|
||||
assert_eq!(tracks.items[0].id, "track-1");
|
||||
}
|
||||
|
||||
@@ -1945,14 +2085,22 @@ mod tests {
|
||||
set_include_catalog_browse(false);
|
||||
let local_only = repo.get_items("lib-1", opts.clone()).await.unwrap();
|
||||
let ids: Vec<&str> = local_only.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["movie-dl"], "toggle off should show downloaded media only");
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["movie-dl"],
|
||||
"toggle off should show downloaded media only"
|
||||
);
|
||||
|
||||
// Toggle ON: both the downloaded and the catalog-only movie are returned.
|
||||
set_include_catalog_browse(true);
|
||||
let full_catalog = repo.get_items("lib-1", opts).await.unwrap();
|
||||
let mut ids: Vec<&str> = full_catalog.items.iter().map(|i| i.id.as_str()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["movie-cat", "movie-dl"], "toggle on should reveal the full catalog");
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["movie-cat", "movie-dl"],
|
||||
"toggle on should reveal the full catalog"
|
||||
);
|
||||
|
||||
// Restore default for other tests sharing this process-global flag.
|
||||
set_include_catalog_browse(true);
|
||||
@@ -1986,7 +2134,10 @@ mod tests {
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
assert!(repo.get_item("ep-1").await.is_ok(), "downloaded episode available offline");
|
||||
assert!(
|
||||
repo.get_item("ep-1").await.is_ok(),
|
||||
"downloaded episode available offline"
|
||||
);
|
||||
assert!(
|
||||
repo.get_item("season-1").await.is_ok(),
|
||||
"season with a season_id-linked downloaded episode should be available offline"
|
||||
@@ -2030,8 +2181,18 @@ mod tests {
|
||||
|
||||
// Simulate the online path persisting the server's library list.
|
||||
let server_libs = vec![
|
||||
Library { id: "music".into(), name: "Music".into(), collection_type: "music".into(), image_tag: None },
|
||||
Library { id: "movies".into(), name: "Movies".into(), collection_type: "movies".into(), image_tag: Some("tag".into()) },
|
||||
Library {
|
||||
id: "music".into(),
|
||||
name: "Music".into(),
|
||||
collection_type: "music".into(),
|
||||
image_tag: None,
|
||||
},
|
||||
Library {
|
||||
id: "movies".into(),
|
||||
name: "Movies".into(),
|
||||
collection_type: "movies".into(),
|
||||
image_tag: Some("tag".into()),
|
||||
},
|
||||
];
|
||||
let saved = repo.save_libraries_to_cache(&server_libs).await.unwrap();
|
||||
assert_eq!(saved, 2);
|
||||
@@ -2039,7 +2200,11 @@ mod tests {
|
||||
// Now offline get_libraries returns them without touching the server.
|
||||
let offline_libs = repo.get_libraries().await.unwrap();
|
||||
let names: Vec<&str> = offline_libs.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["Music", "Movies"], "cached libraries available offline in sort order");
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["Music", "Movies"],
|
||||
"cached libraries available offline in sort order"
|
||||
);
|
||||
|
||||
// Re-saving is idempotent (INSERT OR REPLACE), not duplicating rows.
|
||||
repo.save_libraries_to_cache(&server_libs).await.unwrap();
|
||||
@@ -2066,11 +2231,26 @@ mod tests {
|
||||
|
||||
// Simulate the online path persisting the server's full genre catalog.
|
||||
let server_genres = vec![
|
||||
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(42) },
|
||||
Genre { id: "g2".into(), name: "Jazz".into(), album_count: Some(17) },
|
||||
Genre { id: "g3".into(), name: "Ambient".into(), album_count: None },
|
||||
Genre {
|
||||
id: "g1".into(),
|
||||
name: "Rock".into(),
|
||||
album_count: Some(42),
|
||||
},
|
||||
Genre {
|
||||
id: "g2".into(),
|
||||
name: "Jazz".into(),
|
||||
album_count: Some(17),
|
||||
},
|
||||
Genre {
|
||||
id: "g3".into(),
|
||||
name: "Ambient".into(),
|
||||
album_count: None,
|
||||
},
|
||||
];
|
||||
let saved = repo.save_genres_to_cache(Some("music-lib"), &server_genres).await.unwrap();
|
||||
let saved = repo
|
||||
.save_genres_to_cache(Some("music-lib"), &server_genres)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved, 3);
|
||||
|
||||
// Offline get_genres returns the full set for that library, counts intact.
|
||||
@@ -2085,10 +2265,14 @@ mod tests {
|
||||
assert!(repo.get_genres(Some("other-lib")).await.unwrap().is_empty());
|
||||
|
||||
// Re-saving replaces the scope's rows (server removed "Jazz").
|
||||
let updated = vec![
|
||||
Genre { id: "g1".into(), name: "Rock".into(), album_count: Some(50) },
|
||||
];
|
||||
repo.save_genres_to_cache(Some("music-lib"), &updated).await.unwrap();
|
||||
let updated = vec![Genre {
|
||||
id: "g1".into(),
|
||||
name: "Rock".into(),
|
||||
album_count: Some(50),
|
||||
}];
|
||||
repo.save_genres_to_cache(Some("music-lib"), &updated)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = repo.get_genres(Some("music-lib")).await.unwrap();
|
||||
assert_eq!(after.len(), 1, "stale genres removed on refresh");
|
||||
assert_eq!(after[0].album_count, Some(50), "counts updated on refresh");
|
||||
@@ -2098,24 +2282,37 @@ mod tests {
|
||||
|
||||
/// Helper to seed items into the DB for playlist tests
|
||||
async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
|
||||
let items: Vec<MediaItem> = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect();
|
||||
let items: Vec<MediaItem> = ids
|
||||
.iter()
|
||||
.map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1")))
|
||||
.collect();
|
||||
repo.save_to_cache("library-1", &items).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_empty() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let result = repo.create_playlist("My Playlist", &[]).await;
|
||||
assert!(result.is_ok());
|
||||
let created = result.unwrap();
|
||||
assert!(!created.id.is_empty(), "Should return a non-empty playlist ID");
|
||||
assert!(
|
||||
!created.id.is_empty(),
|
||||
"Should return a non-empty playlist ID"
|
||||
);
|
||||
|
||||
// Verify playlist exists in DB
|
||||
let name: String = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||
Query::with_params(
|
||||
"SELECT name FROM playlists WHERE id = ?",
|
||||
vec![QueryParam::String(created.id.clone())],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
@@ -2126,10 +2323,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_with_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
@@ -2141,10 +2345,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_delete() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["t1"]).await;
|
||||
|
||||
let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("To Delete", &["t1".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Delete it
|
||||
repo.delete_playlist(&created.id).await.unwrap();
|
||||
@@ -2152,7 +2363,10 @@ mod tests {
|
||||
// Verify playlist is gone
|
||||
let count: i32 = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||
Query::with_params(
|
||||
"SELECT COUNT(*) FROM playlists WHERE id = ?",
|
||||
vec![QueryParam::String(created.id.clone())],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
@@ -2162,7 +2376,10 @@ mod tests {
|
||||
// Verify cascade deleted playlist_items
|
||||
let item_count: i32 = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]),
|
||||
Query::with_params(
|
||||
"SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?",
|
||||
vec![QueryParam::String(created.id)],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
@@ -2173,14 +2390,21 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_rename() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let created = repo.create_playlist("Original Name", &[]).await.unwrap();
|
||||
repo.rename_playlist(&created.id, "New Name").await.unwrap();
|
||||
|
||||
let name: String = db_service
|
||||
.query_one(
|
||||
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]),
|
||||
Query::with_params(
|
||||
"SELECT name FROM playlists WHERE id = ?",
|
||||
vec![QueryParam::String(created.id)],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
@@ -2191,10 +2415,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_get_items_preserves_order() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["a", "b", "c"]).await;
|
||||
|
||||
let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
|
||||
assert_eq!(items.len(), 3);
|
||||
@@ -2210,7 +2441,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_get_items_empty_playlist() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
|
||||
let created = repo.create_playlist("Empty", &[]).await.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
@@ -2220,13 +2455,22 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_add_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("Addable", &["t1".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add two more tracks
|
||||
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap();
|
||||
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
@@ -2238,31 +2482,50 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_add_duplicate_items_ignored() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["t1"]).await;
|
||||
|
||||
let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
|
||||
|
||||
// Try to add the same item again
|
||||
repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap();
|
||||
repo.add_to_playlist(&created.id, &["t1".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)");
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
1,
|
||||
"Duplicate should be ignored (UNIQUE constraint)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_remove_items() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||
|
||||
let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items.len(), 3);
|
||||
|
||||
// Remove the middle track by its entry ID
|
||||
let entry_id_to_remove = items[1].playlist_item_id.clone();
|
||||
repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap();
|
||||
repo.remove_from_playlist(&created.id, &[entry_id_to_remove])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let items_after = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
assert_eq!(items_after.len(), 2);
|
||||
@@ -2273,10 +2536,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_forward() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||
|
||||
let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Move 'a' (index 0) to index 2: expect b, c, a, d
|
||||
repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
|
||||
@@ -2289,10 +2559,20 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_backward() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||
|
||||
let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist(
|
||||
"Reorder2",
|
||||
&["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Move 'd' (index 3) to index 0: expect d, a, b, c
|
||||
repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
|
||||
@@ -2305,10 +2585,17 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_item_to_end() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["a", "b", "c"]).await;
|
||||
|
||||
let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
|
||||
repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
|
||||
@@ -2321,13 +2608,22 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn test_playlist_move_nonexistent_item_is_noop() {
|
||||
let db_service = create_test_db();
|
||||
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||
let repo = OfflineRepository::new(
|
||||
db_service.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
);
|
||||
seed_items(&repo, &["a", "b"]).await;
|
||||
|
||||
let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap();
|
||||
let created = repo
|
||||
.create_playlist("NoOp", &["a".into(), "b".into()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Move a nonexistent item - should not error, just no-op
|
||||
repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap();
|
||||
repo.move_playlist_item(&created.id, "nonexistent", 0)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||
|
||||
@@ -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) = (
|
||||
@@ -1148,16 +1293,22 @@ impl MediaRepository for OnlineRepository {
|
||||
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,10 +1416,13 @@ 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 {
|
||||
let source = response
|
||||
.media_sources
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(RepoError::NotFound {
|
||||
message: "No live media source returned".to_string(),
|
||||
})?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -103,10 +103,8 @@ 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);
|
||||
|
||||
@@ -158,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) => {
|
||||
@@ -201,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;
|
||||
|
||||
@@ -251,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,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
|
||||
@@ -302,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
|
||||
@@ -310,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
|
||||
@@ -338,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
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
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;
|
||||
))
|
||||
.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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.0.15",
|
||||
"version": "0.0.16",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
+55
-1
@@ -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
|
||||
*
|
||||
@@ -1206,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
|
||||
*/
|
||||
@@ -1760,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?
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Background-audio support, Android only.
|
||||
*
|
||||
* TRACES: UR-040 | IR-025, DR-051
|
||||
*
|
||||
* Keeps a video's *audio* playing when the app is backgrounded or the screen is
|
||||
* locked, while video decode stops. This is a HANDOFF: the WebView `<video>`
|
||||
* element (which decodes video) is torn down and the same item is played back
|
||||
* audio-only through the native ExoPlayer foreground service. It is NOT the
|
||||
* WebView staying alive — an Android WebView `<video>` does not keep audio
|
||||
* playing once the app is backgrounded.
|
||||
*
|
||||
* The `AndroidBackgroundAudio` @JavascriptInterface (installed by MainActivity)
|
||||
* carries the toggle state to native; native signals background/foreground back
|
||||
* to the frontend as DOM CustomEvents (`jellytau-background` /
|
||||
* `jellytau-foreground`) — see subscribeAppBackgrounded/Foregrounded below.
|
||||
*
|
||||
* Unsupported (no-op) on every non-Android platform.
|
||||
*/
|
||||
|
||||
interface AndroidBackgroundAudioBridge {
|
||||
setEnabled(enabled: boolean): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidBackgroundAudio?: AndroidBackgroundAudioBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidBackgroundAudioBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidBackgroundAudio;
|
||||
}
|
||||
|
||||
/** Whether background audio is available — used to decide if the toggle renders. */
|
||||
export function isBackgroundAudioSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/disarm background-audio mode for the current video. When armed, the native
|
||||
* side runs the audio handoff on background instead of entering PiP.
|
||||
*/
|
||||
export function setBackgroundAudioEnabled(enabled: boolean): void {
|
||||
try {
|
||||
bridge()?.setEnabled(enabled);
|
||||
} catch (err) {
|
||||
console.warn("[BgAudio] Failed to set enabled:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the native "app backgrounded" signal (Home/app-switch/lock).
|
||||
* Returns an unsubscribe function. No-op where unsupported (the event never
|
||||
* fires on non-Android platforms).
|
||||
*/
|
||||
export function subscribeAppBackgrounded(handler: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener("jellytau-background", handler);
|
||||
return () => window.removeEventListener("jellytau-background", handler);
|
||||
}
|
||||
|
||||
/** Subscribe to the native "app foregrounded" signal. Returns an unsubscribe fn. */
|
||||
export function subscribeAppForegrounded(handler: () => void): () => void {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
window.addEventListener("jellytau-foreground", handler);
|
||||
return () => window.removeEventListener("jellytau-foreground", handler);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Picture-in-picture support, Android only.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*
|
||||
* Video on Android renders into a native ExoPlayer SurfaceView behind the
|
||||
* WebView, so PiP is driven by the Activity (which shrinks into a floating
|
||||
* window) rather than the HTML5 `requestPictureInPicture()` API. The bridge is
|
||||
* the `AndroidPictureInPicture` @JavascriptInterface installed by MainActivity.
|
||||
*
|
||||
* On every other platform this module reports unsupported. Notably WebKitGTK
|
||||
* (the Linux webview) does not implement the Picture-in-Picture Web API at all,
|
||||
* so there is no HTML5 fallback to reach for.
|
||||
*/
|
||||
|
||||
interface AndroidPictureInPictureBridge {
|
||||
enterPip(): void;
|
||||
isSupported(): boolean;
|
||||
canEnterPip(): boolean;
|
||||
setAutoEnterEnabled(enabled: boolean): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidPictureInPicture?: AndroidPictureInPictureBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidPictureInPictureBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidPictureInPicture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the device can do PiP at all - used to decide if the button should
|
||||
* be rendered. False on desktop, and on Android devices where the user has
|
||||
* disabled the feature.
|
||||
*/
|
||||
export function isPipSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[PiP] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether entering PiP would succeed right now: a native video must be playing
|
||||
* locally. False during audio playback and while casting to a remote session.
|
||||
*/
|
||||
export function canEnterPip(): boolean {
|
||||
try {
|
||||
return bridge()?.canEnterPip() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[PiP] canEnterPip check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter picture-in-picture. No-op where unsupported. */
|
||||
export function enterPip(): void {
|
||||
try {
|
||||
bridge()?.enterPip();
|
||||
} catch (err) {
|
||||
console.error("[PiP] Failed to enter picture-in-picture:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable auto-entering PiP when the user backgrounds the app.
|
||||
*
|
||||
* This is a coarse frontend override; the authoritative gate is the native
|
||||
* `canEnterPip` guard, which already refuses PiP unless a local video surface
|
||||
* is actively rendering (so audio playback, menu/library browsing, and
|
||||
* remote/cast sessions never enter PiP regardless of this flag). The only
|
||||
* caller today is the background-audio toggle, which disarms auto-PiP so the
|
||||
* two background behaviours stay mutually exclusive.
|
||||
*/
|
||||
export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
try {
|
||||
bridge()?.setAutoEnterEnabled(enabled);
|
||||
} catch (err) {
|
||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user