Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
514e42fccb | ||
|
|
9b1c9b3c91 | ||
|
|
1780109fb1 | ||
|
|
3a18ad060b | ||
|
|
1968c06172 | ||
|
|
ec8a7610f5 | ||
|
|
93d198ce21 | ||
|
|
2b42b74912 | ||
|
|
7660a33dfc | ||
|
|
3e962a202c | ||
|
|
43ddc5a889 | ||
|
|
772e9ca6d5 | ||
|
|
55fa26377a | ||
|
|
f89b241ad6 | ||
|
|
8b028b6b60 | ||
|
|
dd9d4191f1 | ||
|
|
bacb9ca0bb | ||
|
|
cf9472f04f | ||
|
|
f25deba824 | ||
|
|
8f4f651bac | ||
|
|
c175378f38 | ||
|
|
e083b53ee8 | ||
|
|
8f8433eebe | ||
|
|
6f057ad14a | ||
|
|
bebe13eb62 | ||
|
|
a8adbe25cc | ||
|
|
acf1bb200d | ||
|
|
3fbf6afdbc | ||
|
|
4e6ab017d4 | ||
|
|
027054a200 | ||
|
|
1fa5aa46f9 | ||
|
|
7b8a8f66e5 | ||
|
|
2e479d05b3 | ||
|
|
1992a8187d | ||
|
|
532ffa661a | ||
|
|
2a1f1689b4 | ||
|
|
a2cd9978f0 | ||
|
|
36be192d44 | ||
|
|
acb7e5f221 | ||
|
|
68c8602230 | ||
|
|
2d141e5bf4 | ||
|
|
c58cc0cf46 | ||
|
|
8938e3fdba |
@@ -49,6 +49,13 @@ jobs:
|
||||
run: |
|
||||
bun install
|
||||
|
||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
||||
# multi-type includeItemTypes query defining a category in the frontend).
|
||||
# See scripts/check-frontend-boundary.sh and
|
||||
# docs/specs/scoped-search-boundary.md.
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
@@ -60,16 +67,24 @@ jobs:
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
build:
|
||||
name: Build Android APK
|
||||
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||
# the full signed release APK is built only on tag pushes by build-release.yml
|
||||
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
||||
# too would duplicate a ~15min build and, without the sync step, produced an
|
||||
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
||||
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
||||
# breakage without linking, bundling, or signing.
|
||||
android-check:
|
||||
name: Android Compile Check
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
NDK_VERSION: 27.0.11902837
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -97,42 +112,13 @@ jobs:
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install
|
||||
run: bun install
|
||||
|
||||
- name: Build frontend
|
||||
run: bun run build
|
||||
|
||||
- name: Ensure Android NDK
|
||||
run: |
|
||||
if [ ! -d "$NDK_HOME" ]; then
|
||||
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
|
||||
fi
|
||||
echo "Using NDK at $NDK_HOME"
|
||||
ls "$NDK_HOME"
|
||||
|
||||
- name: Initialize Android project
|
||||
- name: Cargo check (aarch64-linux-android)
|
||||
run: |
|
||||
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
||||
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
||||
export AR_aarch64_linux_android="$TC/llvm-ar"
|
||||
cd src-tauri
|
||||
echo "" | bunx tauri android init
|
||||
cd ..
|
||||
|
||||
- name: Build Android APK
|
||||
id: build
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
bun run tauri android build --apk true --target aarch64
|
||||
|
||||
# Find the generated APK file
|
||||
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||
echo "Found artifact: ${ARTIFACT}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-apk
|
||||
path: ${{ steps.build.outputs.artifact }}
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
cargo check --target aarch64-linux-android --lib
|
||||
|
||||
@@ -161,10 +161,10 @@ jobs:
|
||||
|
||||
- name: Set app version from tag
|
||||
run: |
|
||||
REF="${GITHUB_REF#refs/tags/v}"
|
||||
VERSION="${REF#refs/heads/}"
|
||||
# On non-tag runs keep whatever is in tauri.conf.json
|
||||
# On a tag build, the tag is the single source of truth for the
|
||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting version to $VERSION"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||
fi
|
||||
@@ -173,6 +173,35 @@ jobs:
|
||||
- name: Initialize Android project
|
||||
run: bun run tauri android init
|
||||
|
||||
- name: Pin a monotonic Android versionCode
|
||||
run: |
|
||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||
#
|
||||
# Derive an explicit code that is both monotonic in semver order and
|
||||
# always above the 1000 floor already in the field:
|
||||
# code = 1000 + major*10000 + minor*100 + patch
|
||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||
# Guard against a malformed/missing component so we never emit code 0.
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||
echo "version=$VERSION -> versionCode=$CODE"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
cat "$PROPS"
|
||||
|
||||
- name: Sync custom Android sources & gradle config
|
||||
run: ./scripts/sync-android-sources.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Publish Documentation
|
||||
|
||||
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
|
||||
# API reference with cargo doc, and force-pushes the combined output to the
|
||||
# orphan `gitea-pages` branch that the Gitea Pages server serves.
|
||||
#
|
||||
# The published matrix is regenerated during the build, so it is never stale.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
# Only one docs publish at a time; a newer push supersedes an in-flight run.
|
||||
group: publish-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish-docs:
|
||||
name: Build & publish docs to gitea-pages
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
set -e
|
||||
MDBOOK_VERSION=v0.4.40
|
||||
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
|
||||
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
|
||||
mdbook --version
|
||||
|
||||
- name: Regenerate traceability matrix (keep published copy current)
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Assemble mdBook sources
|
||||
run: |
|
||||
set -e
|
||||
# mdBook's src is docs/. Drop in the SUMMARY and the generated
|
||||
# intro + API redirect pages (build artifacts, not committed).
|
||||
cp docs-site/SUMMARY.md docs/SUMMARY.md
|
||||
|
||||
cat > docs/README.md <<'EOF'
|
||||
# JellyTau Documentation
|
||||
|
||||
Cross-platform Jellyfin client — business logic in a Rust backend,
|
||||
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
|
||||
|
||||
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
|
||||
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
|
||||
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
|
||||
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
|
||||
|
||||
_This site is published automatically from `master` by the `publish-docs` CI job._
|
||||
EOF
|
||||
|
||||
cat > docs/api-redirect.md <<'EOF'
|
||||
# Rust API Reference
|
||||
|
||||
The full backend API reference is generated by `cargo doc` (rustdoc).
|
||||
|
||||
👉 **[Open the Rust API Reference](api/index.html)**
|
||||
EOF
|
||||
|
||||
- name: Build mdBook site
|
||||
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Build Rust API docs (cargo doc)
|
||||
working-directory: src-tauri
|
||||
# --no-deps keeps it to our own crate (fast, focused); document private
|
||||
# items so internal modules/commands appear.
|
||||
run: |
|
||||
cargo doc --no-deps --document-private-items
|
||||
# The backend modules/commands live in the LIB crate (jellytau_lib);
|
||||
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
|
||||
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
|
||||
> target/doc/index.html
|
||||
|
||||
- name: Assemble published output
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p "$GITHUB_WORKSPACE/site/api"
|
||||
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
|
||||
# Disable Jekyll processing on the pages branch.
|
||||
touch "$GITHUB_WORKSPACE/site/.nojekyll"
|
||||
ls -la "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Push to gitea-pages branch
|
||||
env:
|
||||
# PAT preferred; falls back to the auto-provided token (same pattern
|
||||
# as build-release.yml).
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
|
||||
|
||||
cd "$GITHUB_WORKSPACE/site"
|
||||
git init -q
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git checkout -q -b gitea-pages
|
||||
git add -A
|
||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
||||
echo "🚀 Force-pushing to gitea-pages"
|
||||
git push -f "$REMOTE" gitea-pages
|
||||
@@ -25,9 +25,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
name: Requirement Traceability Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
name: Validate Requirement Traces
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Extract requirement traces
|
||||
run: bun run traces:json > traces.json
|
||||
|
||||
- name: Validate trace format
|
||||
run: |
|
||||
if ! jq empty traces.json 2>/dev/null; then
|
||||
echo "❌ Invalid traces.json format"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Traces JSON is valid"
|
||||
|
||||
- name: Check requirement coverage
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Extract coverage stats
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
||||
|
||||
echo "## 📊 Requirement Traceability Report"
|
||||
echo ""
|
||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "### Requirements Covered:"
|
||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
||||
echo ""
|
||||
|
||||
# Set minimum coverage threshold (50%)
|
||||
TOTAL_REQS=114
|
||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
||||
echo ""
|
||||
|
||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
||||
fi
|
||||
|
||||
- name: Check for new untraced code
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Find files modified in this PR/push
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "✅ No source files changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "### Files Changed:"
|
||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
||||
echo ""
|
||||
|
||||
# Check if changed files have TRACES
|
||||
UNTRACED_FILES=""
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
# Skip test files and generated code
|
||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file has TRACES comments
|
||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
||||
UNTRACED_FILES+="$file"$'\n'
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ -n "$UNTRACED_FILES" ]; then
|
||||
echo "⚠️ New files without TRACES:"
|
||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "💡 Add TRACES comments to link code to requirements:"
|
||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
||||
else
|
||||
echo "✅ All changed files have TRACES comments"
|
||||
fi
|
||||
|
||||
- name: Generate traceability report
|
||||
if: always()
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Upload traceability report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: traceability-report
|
||||
path: docs/traceability.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with coverage report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
||||
|
||||
const urCount = traces.byType.UR.length;
|
||||
const irCount = traces.byType.IR.length;
|
||||
const drCount = traces.byType.DR.length;
|
||||
const jaCount = traces.byType.JA.length;
|
||||
const total = urCount + irCount + drCount + jaCount;
|
||||
const coverage = Math.round((total / 114) * 100);
|
||||
|
||||
const comment = `## 📊 Requirement Traceability Report
|
||||
|
||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
||||
|
||||
### By Type:
|
||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
||||
|
||||
**Total Traces:** ${traces.totalTraces}
|
||||
|
||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
@@ -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,266 @@
|
||||
# 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`.
|
||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
||||
item-type category sets) leaked into the frontend. See below.
|
||||
- **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.
|
||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
|
||||
## Writing specs
|
||||
|
||||
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
||||
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
|
||||
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
|
||||
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
|
||||
Before accepting a spec, run it past
|
||||
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
|
||||
a spec around "no Rust changes required" — correct layer placement is the goal,
|
||||
not minimal backend churn.
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@wdio/cli": "^9.5.0",
|
||||
"@wdio/local-runner": "^9.5.0",
|
||||
@@ -30,7 +31,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0",
|
||||
},
|
||||
},
|
||||
@@ -46,10 +47,18 @@
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
@@ -348,6 +357,8 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
|
||||
@@ -362,7 +373,7 @@
|
||||
|
||||
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
|
||||
|
||||
@@ -422,6 +433,8 @@
|
||||
|
||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
|
||||
@@ -498,6 +511,8 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
@@ -686,6 +701,8 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
@@ -738,6 +755,12 @@
|
||||
|
||||
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
@@ -756,7 +779,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
@@ -828,6 +851,10 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -1020,7 +1047,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
|
||||
|
||||
@@ -1042,7 +1069,7 @@
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
|
||||
|
||||
@@ -1068,7 +1095,7 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
|
||||
|
||||
@@ -1166,6 +1193,10 @@
|
||||
|
||||
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -1190,10 +1221,24 @@
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -1260,6 +1305,8 @@
|
||||
|
||||
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
|
||||
|
||||
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
@@ -1298,6 +1345,12 @@
|
||||
|
||||
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
|
||||
|
||||
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
@@ -1328,7 +1381,7 @@
|
||||
|
||||
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
"@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
@@ -1338,34 +1391,24 @@
|
||||
|
||||
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -1432,8 +1475,6 @@
|
||||
|
||||
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
@@ -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
|
||||
@@ -51,14 +51,19 @@ graph TD
|
||||
|
||||
**View Enforcement:**
|
||||
|
||||
Ordinal content (where position carries meaning) is always a list. Everything
|
||||
else honours the user's persisted grid/list preference — see
|
||||
[ux-flows.md §5A.2](../ux-flows.md).
|
||||
|
||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||
|--------------|-----------|----------------|----------------|
|
||||
| Tracks | List (forced) | No | `TrackList` |
|
||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Artists | User preference | Yes | `LibraryGrid` |
|
||||
| Albums | User preference | Yes | `LibraryGrid` |
|
||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||
|
||||
**TrackList Component:**
|
||||
|
||||
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
|
||||
/>
|
||||
```
|
||||
|
||||
**LibraryGrid forceGrid Prop:**
|
||||
**LibraryGrid view mode:**
|
||||
|
||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||
prop controls whether the toggle buttons appear in the page header; the grid
|
||||
itself always follows the stored preference.
|
||||
|
||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||
preference. No caller ever passed it, so it was removed — pages that were
|
||||
documented as "forced grid" have in practice always honoured the toggle.
|
||||
|
||||
## Playback Reporting Service
|
||||
|
||||
|
||||
+87
-1
@@ -50,6 +50,24 @@ 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) |
|
||||
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,6 +103,11 @@ 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 |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -123,6 +146,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 +204,39 @@ 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) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +255,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 +285,24 @@ 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 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
|
||||
---
|
||||
|
||||
@@ -295,6 +370,14 @@ 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 |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
|
||||
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -312,6 +395,9 @@ 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 |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Pending |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Spec review checklist
|
||||
|
||||
Run a spec past this before accepting it. It exists because JellyTau's
|
||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||
This checklist is the human gate. The CI check
|
||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||
signature — it does **not** replace this.
|
||||
|
||||
Copy the boxes into the review comment (or the PR) and tick them.
|
||||
|
||||
## Boundary (the one that bites)
|
||||
|
||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||
*logic*, not files. A spec without this section is not ready to review.
|
||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||
query-shaping rules, business rules, reachability/sync policy. If the
|
||||
frontend names a *set* of item types to define a category, that is a leak —
|
||||
it belongs behind an opaque enum the backend expands.
|
||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||
the decision of its value.
|
||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||
frontend. Borderline defaults to Rust.
|
||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||
is **not** over-corrected into the backend. The rule targets category
|
||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||
list page.
|
||||
|
||||
## IPC contract
|
||||
|
||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||
|
||||
## Requirements & traceability
|
||||
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||
pointing here — no two specs silently contradicting.
|
||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||
|
||||
---
|
||||
|
||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||
assignment first. Every other section can be negotiated; that one is the whole
|
||||
reason this file exists.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Spec: <feature name>
|
||||
|
||||
<!--
|
||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||
comments as you go. The section that matters most for this project is
|
||||
"Layer assignment" — read its comment before writing it.
|
||||
|
||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||
-->
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||
|
||||
## Motivation
|
||||
|
||||
<!-- Why now. The problem being solved. -->
|
||||
|
||||
## Layer assignment
|
||||
|
||||
<!--
|
||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||
do NOT reframe it as "how little backend work can we get away with."
|
||||
|
||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||
navigation, view/order preferences, input handling.
|
||||
|
||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||
the frontend — the backend accepting a parameter does not make deciding that
|
||||
parameter's value a presentation concern.
|
||||
|
||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||
yes (and only yes), it is presentation → frontend.
|
||||
|
||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||
means as a set of Jellyfin types) in the frontend because the backend already
|
||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||
at spec time, not in review three features later.
|
||||
-->
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||
|
||||
<!--
|
||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||
-->
|
||||
|
||||
## Design
|
||||
|
||||
<!--
|
||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||
- Command NAME must match the Rust fn name exactly.
|
||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||
- Events are kebab-case.
|
||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||
|
||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||
-->
|
||||
|
||||
## Out of scope
|
||||
|
||||
<!-- What this spec deliberately does NOT do. -->
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- Checkable statements. Include the standard gates: -->
|
||||
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||
|
||||
## TRACES
|
||||
|
||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
<!--
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||
- Anything else non-obvious.
|
||||
-->
|
||||
@@ -0,0 +1,164 @@
|
||||
# Spec: Account menu and global chrome availability
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend only. No Rust changes required.
|
||||
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||
[requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||
account menu anchored to the user's name, and make that menu available on every
|
||||
authenticated non-immersive screen.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||
belongs to the library layout. The user has to guess that account actions live
|
||||
*inside* Library — an unrelated section — and navigate there first.
|
||||
|
||||
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||
same two actions are found two different ways.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The header is not global.** It is defined in
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||
all.
|
||||
|
||||
2. **`routeOwnsLayout`** in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||
flex column. Everything else renders into the root scroller with the root's
|
||||
`BottomUi` below it.
|
||||
|
||||
3. **Bottom nav is Home / Search / Library only**
|
||||
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||
or account entry.
|
||||
|
||||
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||
Settings or Sign out.
|
||||
|
||||
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||
trigger.
|
||||
|
||||
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||
|
||||
7. **`viewMode` is already a persisted store** in
|
||||
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||
`localStorage`). The Display setting is a second view onto it — **no new
|
||||
state, no migration.**
|
||||
|
||||
## Design
|
||||
|
||||
### `AccountMenu` component (DR-075)
|
||||
|
||||
One component used by both breakpoints. Contents in fixed order:
|
||||
|
||||
```
|
||||
Signed in as <name> ← identity block, not interactive
|
||||
<server host>
|
||||
────────────────────────
|
||||
Downloads
|
||||
Settings
|
||||
Display ← grid/list preference
|
||||
────────────────────────
|
||||
Sign out ← destructive, last, after a divider
|
||||
```
|
||||
|
||||
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||
name shows inside the open menu regardless.
|
||||
- **Same items, same order, both platforms.**
|
||||
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||
and focus return to the trigger on close.
|
||||
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||
`aria-expanded` on the trigger.
|
||||
|
||||
"Display" may either navigate to the Settings Display section or expose the
|
||||
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||
preferences and avoids a nested control inside a dropdown.
|
||||
|
||||
### Global chrome (DR-076)
|
||||
|
||||
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||
and `/downloads`.
|
||||
|
||||
The cleanest route is to lift the header out of the library layout into a shared
|
||||
component rendered by the root layout, with the library layout consuming the
|
||||
same component rather than defining its own. **Do not duplicate the markup into
|
||||
each route.**
|
||||
|
||||
Constraints that must survive the change:
|
||||
|
||||
- `/player/*` and `/login` stay chrome-free.
|
||||
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||
already there), but must not double up on chrome.
|
||||
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||
own column. Preserve the scroll containment; a regression here reintroduces
|
||||
the "last row hidden behind the nav" bug called out in those comments.
|
||||
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||
`showBottomNav`) must be unchanged.
|
||||
|
||||
### Display section in Settings (DR-077)
|
||||
|
||||
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||
with the grid/list control bound to the existing `viewMode` store via
|
||||
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||
controls drive the same store, so they stay in sync for free.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the Settings page or reorganising its existing sections.
|
||||
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||
active server but offers no switcher.
|
||||
- Changing the bottom nav's three destinations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||
without first navigating into Library.
|
||||
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||
- [ ] The username/avatar opens the menu; it is a real button with
|
||||
`aria-expanded`.
|
||||
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||
state + redirects as it does today.
|
||||
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||
immediately reflected by the library page-header toggle (same store).
|
||||
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||
visibility on any route.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||
logout handler.
|
||||
- Display setting: writes through to the `viewMode` store and persists.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||
`UR-054, UR-029 | DR-077`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The layout shell is subtle and the existing comments record real bugs that
|
||||
were fixed there. Read them before restructuring.
|
||||
- Another session may be active in this repo, including in
|
||||
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||
unexpected changes, and expect to coordinate on that file.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Spec: Downloads as a browsable offline library
|
||||
|
||||
**Status:** Draft — ready to implement
|
||||
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the flat Active/Completed download list with two views under
|
||||
`/downloads`:
|
||||
|
||||
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||
using the *same* browse screens as online (grids, cards, detail pages).
|
||||
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||
showing only in-flight transfers.
|
||||
|
||||
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||
pages, a device total, and the remove confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||
offline" and "what is downloading" are different questions crammed into one flat
|
||||
list. Browsing offline should feel exactly like browsing online.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The offline repository is already a browsable tree.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||
filter to downloaded content via CTEs. This is the data source for
|
||||
Downloaded; **do not build a new query layer.**
|
||||
|
||||
2. **The client cannot reach it independently.**
|
||||
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||
`repositoryGetItems` always goes through the **hybrid** repository
|
||||
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||
server. There is no "offline only" browse path exposed. This is the one real
|
||||
backend gap (DR-082).
|
||||
|
||||
3. **Downloads page is a flat two-tab list.**
|
||||
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||
|
||||
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||
given. Downloaded browse is those components with an offline-scoped source.
|
||||
|
||||
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||
and falls through to the server. The offline-only browse path (DR-082) must
|
||||
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||
downloaded here."
|
||||
|
||||
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||
|
||||
## Design
|
||||
|
||||
### View split (DR-081)
|
||||
|
||||
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||
default. Transfers shows a count/badge only while transfers are active.
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||
does not start downloads.
|
||||
|
||||
### Offline-scoped browse source (DR-082, DR-083)
|
||||
|
||||
Add an explicit offline-only browse path so Downloaded never merges server
|
||||
results and never depends on reachability. Two viable shapes — pick per the
|
||||
codebase, do not do both:
|
||||
|
||||
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||
`_libraries`) that calls the offline repository directly, with a matching
|
||||
client method; or
|
||||
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||
|
||||
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||
fallthrough), and the path is available while the server is reachable (a user
|
||||
online still wants to browse their downloads).
|
||||
|
||||
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||
source. Omit libraries and containers with no downloaded content. Badge
|
||||
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||
available at item / album / season / series level and removes a container from
|
||||
the browse when its last downloaded child goes.
|
||||
|
||||
### Transfers view (DR-084)
|
||||
|
||||
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||
this view — they appear in Downloaded. Empty state points at the library.
|
||||
|
||||
### Disk usage (DR-085, UR-056)
|
||||
|
||||
- **Source the bytes from the download manager** — it writes the files and can
|
||||
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||
This is display + aggregation, **not** new tracking.
|
||||
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||
it everywhere.
|
||||
- **Surface it in familiar places:** a secondary size label on the card and
|
||||
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||
that reconciles with the listed sum; a reclaim figure in the remove
|
||||
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||
that governs the *online offline-fallback* library; this is the dedicated
|
||||
Downloads surface. They should be consistent but are separate work.
|
||||
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||
on that behaviour here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||
the same grids/cards/detail pages as online browsing.
|
||||
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||
through to the server.
|
||||
- [ ] Play from Downloaded plays the local file.
|
||||
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||
transfers move to Downloaded.
|
||||
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||
shown and reconciles with the sum; remove states the reclaim amount.
|
||||
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||
`cargo clippy` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Repository client: the offline-only browse path returns downloaded content and
|
||||
its containers, and an empty result does **not** trigger server fallthrough.
|
||||
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||
partial/full container badging.
|
||||
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||
total reconciles with listed items.
|
||||
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The offline repository already does the hard part. The main work is a clean
|
||||
offline-only client path and reusing the library components — resist
|
||||
rebuilding browse UI.
|
||||
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Spec: Remove Jellyfin-specific models from the frontend
|
||||
|
||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||
> classification* and *time units* no longer speak Jellyfin:
|
||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||
> - User-facing type badge → `kindLabel()`.
|
||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||
>
|
||||
> **Deferred (tracked, not done):**
|
||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||
|
||||
**Status:** Partially implemented (catalog surface); see banner.
|
||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||
architecture/boundary migration.
|
||||
**Supersedes / revises:** none. Extends the boundary work started in
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||
*whole media model*.
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||
comparisons across 23 files** and two frontend utility modules
|
||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||
field conversion in the presentation layer.
|
||||
|
||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||
names — remains in `src/`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two concrete problems, one strategic:
|
||||
|
||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||
is domain knowledge in the wrong layer, 36 files deep.
|
||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||
should have already resolved.
|
||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||
shut.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||
|
||||
**Borderline calls, resolved:**
|
||||
|
||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||
subset of the new domain model, not a special case.
|
||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||
`item_type`. Only the Jellyfin item-type strings move.
|
||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||
self-contained — deferred to a late phase, not phase 1.
|
||||
|
||||
## Design
|
||||
|
||||
### Single canonical model, one location, isolated mappings
|
||||
|
||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||
whole app:
|
||||
|
||||
```
|
||||
src-tauri/src/domain/
|
||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||
mod.rs re-exports
|
||||
| tauri-specta (export_typescript_bindings test)
|
||||
v
|
||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||
```
|
||||
|
||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||
projection of it. There is no second hand-written copy in either language, so it
|
||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||
not duplication.
|
||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||
touches the domain type. Adding a second provider later means a new
|
||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||
repository-specific.
|
||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||
translation instead of copying `item_type` through.
|
||||
|
||||
### The domain model (Rust)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
Track, Album, Artist, Playlist, // music
|
||||
Movie, Series, Season, Episode, // video
|
||||
Person, // cast/crew
|
||||
Channel, Folder, // containers/live
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: MediaKind, // was: type: String
|
||||
pub is_folder: bool,
|
||||
pub server_id: String,
|
||||
|
||||
// Times in milliseconds — NEVER ticks.
|
||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||
|
||||
// Image as a resolved identifier the frontend turns into a URL via the
|
||||
// existing image command — no raw Jellyfin tag semantics leak.
|
||||
pub image_id: Option<String>, // was: primary_image_tag
|
||||
pub backdrop_image_ids: Option<Vec<String>>,
|
||||
|
||||
pub overview: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub production_year: Option<i32>,
|
||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||
pub community_rating: Option<f64>,
|
||||
pub official_rating: Option<String>,
|
||||
|
||||
// Relationships — already neutral, kept.
|
||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||
|
||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||
pub track_number: Option<i32>, // was: index_number
|
||||
pub disc_number: Option<i32>, // was: parent_index_number
|
||||
|
||||
pub user_data: Option<UserData>,
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
```
|
||||
|
||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||
copies `item_type` straight across — is enriched into the single, tested place
|
||||
that:
|
||||
|
||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||
`Other` variant — **decide at implementation; must not panic.**
|
||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||
|
||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||
|
||||
### 🔴 The `search-event` / dual-payload rule applies again
|
||||
|
||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||
and any other event payloads — emits the new domain shape. Both sides of a
|
||||
twice-delivered result must match (same rule as
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||
event definitions before declaring a phase done.
|
||||
|
||||
### Frontend after
|
||||
|
||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||
they exist, which are not Jellyfin-specific).
|
||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||
|
||||
## Phased migration
|
||||
|
||||
This is too large and too collision-prone for one change. Phases are independently
|
||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||
|
||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||
wire shape is a superset of today's, so the frontend still compiles and
|
||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||
new ones default).
|
||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||
`bun run check` is the driver.
|
||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||
outside tests.
|
||||
|
||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||
- Changing any user-visible behaviour, layout, or copy.
|
||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||
demands it.
|
||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||
item type (Rust test with a garbage type string).
|
||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||
same navigation, offline included.
|
||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||
critical surface —
|
||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||
- unknown type string → safe fallback, no panic;
|
||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||
|
||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||
branching matches the old `type`-based branching for a representative mix.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||
frontend files and the core Rust types. A parallel Claude session in any media
|
||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||
the disruptive flip. Resist the urge to skip it.
|
||||
- IPC camelCase rules apply to the new enums/structs
|
||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||
`bindings.ts`, never hand-edit.
|
||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||
Layer assignment table above is the load-bearing section.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||
commands, no schema changes, no UI additions.
|
||||
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||
media does not work."*
|
||||
|
||||
## Summary
|
||||
|
||||
Offline, a library page is supposed to show **only media on the device**, with a
|
||||
"Show all server media" toggle that additionally reveals the cached server
|
||||
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||
does not gate the listing — every server item still appears. This spec fixes
|
||||
that with two independent changes; either one alone leaves the bug visible.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code. **The feature is built and mostly correct — this is a
|
||||
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||
command, or the SQL gate.
|
||||
|
||||
1. **The SQL gate works and is unit-tested.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||
true; with it false, only downloaded/local rows return. Guarded by
|
||||
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||
query.**
|
||||
|
||||
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||
`set_show_server_catalog` command
|
||||
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||
`include = connected || showCatalog` and pushes it on every change.
|
||||
|
||||
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||
them.
|
||||
|
||||
4. **`MediaCard` already greys and queues.**
|
||||
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||
it.
|
||||
|
||||
## The two defects
|
||||
|
||||
### Defect A — offline is never actually entered (DR-079)
|
||||
|
||||
`pushCatalogVisibility` keys off `isConnected`, but
|
||||
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||
|
||||
```ts
|
||||
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||
```
|
||||
|
||||
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||
stays true, so `include` stays true, so the backend keeps returning the full
|
||||
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||
gets a chance to gate anything.
|
||||
|
||||
This is the primary cause: it explains why the filter looks dead rather than
|
||||
merely inverted — the gate never closes.
|
||||
|
||||
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||
|
||||
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||
correctly returns few or zero rows. But
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||
~10 other reads) falls through to the server and returns the full server list —
|
||||
re-defeating the filter even after Defect A is fixed.
|
||||
|
||||
## Design
|
||||
|
||||
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||
|
||||
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||
store:
|
||||
|
||||
```ts
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
```
|
||||
|
||||
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||
immediate recheck (`online`/`offline` listeners already call
|
||||
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||
Leave `isOnline` on the state object and the listeners intact.
|
||||
|
||||
Consider whether the optimistic `isServerReachable: true` startup default
|
||||
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||
choice in a comment.
|
||||
|
||||
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||
anything importing it). Enumerate consumers first:
|
||||
|
||||
```
|
||||
grep -rn "isConnected" src/ | grep -v node_modules
|
||||
```
|
||||
|
||||
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||
trigger. It almost always is — that is the whole point of the reachability model
|
||||
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||
device link in the PR description.
|
||||
|
||||
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||
|
||||
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||
for downloads only and there are none here." The gate flag already encodes intent
|
||||
— reuse it.
|
||||
|
||||
Add a getter beside the existing setter in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||
|
||||
```rust
|
||||
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||
```
|
||||
|
||||
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||
behaviour is unchanged: empty cache still falls through to the server.
|
||||
|
||||
Keep it surgical:
|
||||
|
||||
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||
thread it into `parallel_race` or the other readers, which have no catalog
|
||||
gate and legitimately want the server on an empty cache.
|
||||
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||
while reachable) the method behaves as it does today, including the background
|
||||
cache refresh on a hit.
|
||||
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||
|
||||
### Why both
|
||||
|
||||
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||
downloads present the list still gets padded by the server fallback whenever a
|
||||
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||
never goes false on a live link. Ship them together.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||
- Home-screen and resume queries — already downloads-only.
|
||||
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||
- Any new IPC command, DB column, or settings entry.
|
||||
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||
UX-placement question, tracked separately under UR-051's toggle note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [~] With the server unreachable on a live device link, a library page lists
|
||||
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||
coverage via UT-069 + gate tests).
|
||||
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||
- [x] A library with downloads and a thin cache does not get padded with
|
||||
non-downloaded server items when offline with the toggle off (Defect B —
|
||||
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||
offline (UT-069).
|
||||
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||
while reachable) `get_items` keeps the offline fast-path and background
|
||||
refresh (UT-067 + gate-on fall-through test).
|
||||
- [~] A download queued from a greyed offline card resolves and starts on
|
||||
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||
resume path untouched).
|
||||
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||
touched files).
|
||||
|
||||
## Testing
|
||||
|
||||
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||
|
||||
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||
result as-is and does **not** query the server. Assert via a mock online repo
|
||||
whose `get_items` bumps a call counter that must stay at zero.
|
||||
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||
|
||||
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||
|
||||
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||
unreachable with `navigator.onLine === true`; true when reachable with
|
||||
`navigator.onLine === false`.
|
||||
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||
and pushes to the backend on a change of either input (extend the existing
|
||||
offlineCatalog tests).
|
||||
|
||||
Integration (IT-016, IT-017) are documented as pending in
|
||||
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||
|
||||
New/changed requirement code keeps its `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||
before Fix A — it is the canonical statement of the reachability model this fix
|
||||
restores fidelity to.
|
||||
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||
ordering already holds for the SQL gate today. No new locking.
|
||||
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||
other new rows.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** Rust + Frontend. **Revises a decision in
|
||||
[scoped-search.md](scoped-search.md).**
|
||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||
architecture/boundary change with **no user-visible behaviour difference**.
|
||||
|
||||
## Why this spec exists
|
||||
|
||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||
|
||||
The offending knowledge lives in
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||
|
||||
```ts
|
||||
const SCOPE_ITEM_TYPES = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
const GROUP_ITEM_TYPES = {
|
||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||
creates: the day the backend starts returning a type the frontend never
|
||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||
drops it from both the query filter and the result buckets, and nothing in the
|
||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||
sources of truth that will drift.
|
||||
|
||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||
baked into a released wire contract.
|
||||
|
||||
### What is *not* a leak (leave it alone)
|
||||
|
||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||
|
||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||
`tv.ts` → `["Series"]`
|
||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||
`PersonDetailView`
|
||||
|
||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||
the line. Do **not** invent a backend enum for every list page — that is
|
||||
over-abstraction, not cleaner separation.
|
||||
|
||||
## The boundary rule after this change
|
||||
|
||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||
> groups. The frontend owns only **group order** (presentation) and
|
||||
> **rendering**.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust owns scope → item-types (query side)
|
||||
|
||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||
|
||||
```rust
|
||||
// repository/types.rs
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope { All, Music, Movies, Tv }
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or None for `All`
|
||||
/// (which must send NO includeItemTypes — see below).
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter().map(String::from).collect()),
|
||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||
online/offline paths (which already honour `include_item_types` — do not touch
|
||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||
|
||||
```rust
|
||||
pub struct SearchOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub search_term: Option<String>,
|
||||
pub scope: Option<SearchScope>, // NEW
|
||||
// include_item_types stays for the single-type list-page callers,
|
||||
// but the SEARCH command derives it from `scope` when scope is set.
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||
non-search `getItems` callers. Document this so a future reader does not send
|
||||
both.
|
||||
|
||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||
|
||||
### Rust owns result bucketing (result side)
|
||||
|
||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||
```
|
||||
|
||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||
to no group are omitted from grouped output (same as today's frontend filter).
|
||||
|
||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||
|
||||
Search returns results **twice**: the command resolves with instant local-cache
|
||||
results, then the merged cache+server union arrives later via the `search-event`
|
||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||
command return value **and** the `search-event` payload must carry
|
||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||
is the single largest part of the change and the easiest to half-do.
|
||||
|
||||
### What the frontend keeps (all pure presentation)
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||
|
||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||
Rust enum (delete the hand-written union).
|
||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||
unit-tested. **Stays exactly as-is.**
|
||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||
presentation.
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||
|
||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||
|
||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||
groups** — no `.type` inspection anywhere:
|
||||
|
||||
```ts
|
||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||
```
|
||||
|
||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||
it as a display hint, not authority.
|
||||
|
||||
### Frontend call-site changes
|
||||
|
||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||
event merge) is preserved.
|
||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||
results.
|
||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||
behaviour; only the type it passes to `SearchResults` changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||
works; only the *source* of the type list moves.
|
||||
- Single concrete-type list pages (see "What is not a leak").
|
||||
- Ranking within or across groups.
|
||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||
[scoped-search.md](scoped-search.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||
in `searchScope.ts` or any search call path. Verify:
|
||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||
`bindings.ts`, not hand-written unions.
|
||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||
groups, same order, same empty/out-of-scope omission, offline included.
|
||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||
shape; no shape flicker between instant and merged results.
|
||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||
committed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`src-tauri`, `cargo test`):
|
||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||
`scope: All` sends no `include_item_types`.
|
||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||
unknown types are dropped; groups come out in canonical order.
|
||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||
|
||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||
compose-over-groups (order + empty-omit, no type inspection).
|
||||
- `searchGroupOrder.test.ts` — unchanged.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||
the wire shape.
|
||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||
§Design "Scope model / Threading scope through the store," which asserted no
|
||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||
banner pointing here — do not leave the two specs contradicting silently.
|
||||
- The IPC camelCase rule applies to the new enums and structs
|
||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||
`tauriIntegration`-style test if a new command is introduced.
|
||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||
types; do not hand-edit it.
|
||||
- **Another Claude session may be active in these same files** (per project
|
||||
memory). `git diff` before repairing anything unexpected; these search files
|
||||
are exactly the ones a parallel session touched.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Spec: Context-scoped search with filter chips and configurable group order
|
||||
|
||||
> ⚠️ **Superseded in part by
|
||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||
§6.3 group order, §6.4 current deviations.
|
||||
|
||||
## Summary
|
||||
|
||||
Two related changes to search:
|
||||
|
||||
1. **Scope** — a search started inside a library searches *that* library.
|
||||
Started from Home, `/library`, or the search tab, it searches everything.
|
||||
The active scope shows as a chip row under the search bar, preselected from
|
||||
context and freely changeable without retyping.
|
||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||
|
||||
## Motivation
|
||||
|
||||
Searching "office" while browsing TV currently returns music albums, because
|
||||
both search entry points call the same unscoped query. The user has already
|
||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||
and pushes the relevant result below unrelated media.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code — **most of the plumbing is already there.** This is
|
||||
substantially a wiring task, not new infrastructure.
|
||||
|
||||
1. **`SearchOptions` already carries the filter.**
|
||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||
|
||||
2. **Rust already honours `include_item_types` on both paths** — online
|
||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||
options mapping) and offline
|
||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||
type filter from it). **Do not add Rust code for filtering.**
|
||||
|
||||
3. **Per-page list search already does this correctly.**
|
||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||
the reference for the call shape, including the `requestId` handling.
|
||||
|
||||
4. **The gap is exactly one function.**
|
||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||
any scope. Both callers
|
||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||
it.
|
||||
|
||||
5. **Group order is hardcoded in markup.**
|
||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||
renders three fixed sections in source order.
|
||||
|
||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||
command for this.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope model
|
||||
|
||||
One `SearchScope` type, defined once and shared:
|
||||
|
||||
| Scope | `includeItemTypes` | Chip label |
|
||||
|-------|--------------------|------------|
|
||||
| `all` | *unset* | All |
|
||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||
| `movies` | `Movie` | Movies |
|
||||
| `tv` | `Series`, `Episode` | TV |
|
||||
|
||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||
|
||||
### Route → scope resolution (DR-063)
|
||||
|
||||
A pure function, unit-testable without a DOM:
|
||||
|
||||
```ts
|
||||
resolveSearchScope(pathname: string): SearchScope
|
||||
```
|
||||
|
||||
- `/library/music*` → `music`
|
||||
- `/library/movies*` → `movies`
|
||||
- `/library/tv*` → `tv`
|
||||
- `/`, `/library`, `/search`, anything else → `all`
|
||||
|
||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||
current route list before finalising — do not assume this table is exhaustive.
|
||||
|
||||
### Scope is a starting point, not a lock (DR-064)
|
||||
|
||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||
their choice governs until they leave the search surface. Concretely: derive the
|
||||
initial value from the route, hold it in component state, and do not re-derive
|
||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||
|
||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||
keeps the current scope.
|
||||
|
||||
### Threading scope through the store (DR-065)
|
||||
|
||||
Extend the store's search signature to accept an optional scope and pass
|
||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||
additive parameter — no caller should break.
|
||||
|
||||
### Group order (DR-066, DR-067)
|
||||
|
||||
Persist an ordered array of group ids:
|
||||
|
||||
```
|
||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||
```
|
||||
|
||||
Rendering composes scope and order as **two independent axes**, in this order:
|
||||
|
||||
1. drop groups outside the active scope,
|
||||
2. sort the remainder by the user's saved order,
|
||||
3. omit groups that came back empty.
|
||||
|
||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||
the worked example.
|
||||
|
||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||
unusable with a screen reader and on any pointerless input.
|
||||
|
||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||
the new ones.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Ranking *within* a group. Order is presentation-only.
|
||||
- Server-side search ranking or the Jellyfin query itself.
|
||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||
is already implicitly scoped by its own `itemType`.
|
||||
- Any Rust change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||
- [ ] The chip row renders under the search bar on both the search page and the
|
||||
in-library header search, with the context-derived chip preselected.
|
||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||
query preserves the selected chip.
|
||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||
empty groups omitted and relative order preserved.
|
||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||
restarts, and ships with the documented default.
|
||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||
don't reimplement).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||
|
||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||
- The compose step: scope filter + user order + empty-group omission, including
|
||||
the "narrow then widen restores order" case and a stored order containing an
|
||||
unknown id.
|
||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||
the existing stale-`requestId` guard still discards superseded responses.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||
document is the implementation plan.
|
||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||
- Another session may be active in this repo. Check `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -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.
|
||||
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
|
||||
## Gitea Actions Workflows
|
||||
|
||||
Two workflows are configured in `.gitea/workflows/`:
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
||||
Gitea-native workflow with:
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request
|
||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||
|
||||
### 2. `traceability.yml` (Alternative)
|
||||
GitHub-compatible workflow with additional features:
|
||||
- Pull request comments with coverage stats
|
||||
- GitHub-specific integrations
|
||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||
`traceability-check.yml` rather than reviving the old file.
|
||||
|
||||
## What Gets Validated
|
||||
|
||||
|
||||
+2412
-604
File diff suppressed because it is too large
Load Diff
+590
-99
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
||||
- Logo (links to `/library`)
|
||||
- Navigation links: Home, Library, Downloads, Settings
|
||||
- Search bar (inline)
|
||||
- User menu: Username, Downloads icon, Logout button
|
||||
- Account menu (see §1.2)
|
||||
|
||||
**Mobile Navigation:**
|
||||
|
||||
On mobile, the header contains:
|
||||
- Logo
|
||||
- Three-dot overflow menu button (Android-style)
|
||||
- Overflow menu includes:
|
||||
- Downloads
|
||||
- Settings
|
||||
- Sign out
|
||||
- Account menu button (see §1.2)
|
||||
|
||||
### 1.2 Account Menu
|
||||
|
||||
Account-level destinations — the ones that are *about the user* rather than
|
||||
about media — live behind a single **account menu**, anchored to the user's
|
||||
name/avatar at the right of the header.
|
||||
|
||||
**Contents, in order:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Signed in as <name> │ ← identity, not a menu item
|
||||
│ <server host> │
|
||||
├──────────────────────────┤
|
||||
│ ⬇ Downloads │
|
||||
│ ⚙ Settings │
|
||||
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||||
├──────────────────────────┤
|
||||
│ ⇥ Sign out │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **One menu, both platforms.** Desktop and mobile show the same items in the
|
||||
same order. A user who learns where Settings lives on one form factor finds
|
||||
it in the same place on the other.
|
||||
- **Anchored to identity.** The trigger is the username/avatar, because that is
|
||||
where users look for account actions. A bare three-dot icon does not signal
|
||||
"your account".
|
||||
- **Sign out is separated** by a divider and placed last — it is destructive and
|
||||
must not sit adjacent to routine navigation.
|
||||
- **The menu is reachable from every authenticated screen**, not only from
|
||||
library routes. See §1.3.
|
||||
|
||||
**Access Points Summary:**
|
||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||
- **Downloads** → header icon (desktop) + account menu (both)
|
||||
- **Settings** → header nav link (desktop) + account menu (both)
|
||||
- **Sign out** → account menu only
|
||||
|
||||
### 1.3 Chrome availability
|
||||
|
||||
The header is shared across chrome-bearing routes. Routes fall into three groups:
|
||||
|
||||
| Route group | Header | Bottom nav | Account menu reachable? |
|
||||
|-------------|--------|------------|-------------------------|
|
||||
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
|
||||
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
|
||||
| `/settings` | Own layout | No | n/a — already there |
|
||||
| `/player/*`, `/login` | No | No | No (by design) |
|
||||
|
||||
The rule the app honours: every authenticated, non-immersive screen exposes the
|
||||
account menu. Only the full-screen player and the login screen are chrome-free.
|
||||
|
||||
### 1.4 Known deviations
|
||||
|
||||
*(None — the account-menu and chrome-availability defects tracked here under
|
||||
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||||
from every authenticated non-immersive screen via the shared `AccountMenu`, the
|
||||
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||||
Display preference has a Settings entry — UR-029, §5A.4.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -386,9 +439,9 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||||
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||
|
||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||
@@ -445,54 +498,352 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
## 5A. Library Page Layouts
|
||||
|
||||
### 6.1 Search Page Navigation
|
||||
Every browse page is one of two shapes: a **card grid** or a **row list**. This
|
||||
section is the rule for which shape a page takes, what a card looks like, and
|
||||
what the user is allowed to change.
|
||||
|
||||
### 5A.1 Card shape follows the media, not the page
|
||||
|
||||
Card aspect ratio is a property of *what the item is*, and is never overridden
|
||||
per-page. This is the single most important layout rule: a user scanning a grid
|
||||
recognises content type by silhouette before reading a word.
|
||||
|
||||
| Item type | Aspect | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
|
||||
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
|
||||
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
|
||||
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
|
||||
|
||||
Artist cards are square but rendered **circular-masked**, so artists are
|
||||
distinguishable from albums at a glance within the same music grid.
|
||||
|
||||
### 5A.2 Grid vs. list
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||
Page[Library browse page] --> Kind{Content kind}
|
||||
|
||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||||
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||||
|
||||
SearchPage --> EmptyState{Has Query?}
|
||||
|
||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||
|
||||
ShowPrompt --> UserTypes[User Types in Search]
|
||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||
LiveSearch --> ShowResults
|
||||
|
||||
ShowResults --> UserClick{User Clicks Result}
|
||||
|
||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||
Grid --> Toggle[View toggle in page header]
|
||||
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||
```
|
||||
|
||||
**Search Page Layout:**
|
||||
- **Grids are the default** for anything with cover art worth scanning.
|
||||
- **Lists are mandatory, not optional**, where position carries meaning —
|
||||
a track's number within an album, an episode's number within a season.
|
||||
A grid destroys that ordering cue, so these pages expose **no toggle**.
|
||||
- **The toggle is global, not per-page.** A user who prefers dense lists
|
||||
prefers them everywhere; making them re-set it on each page is friction.
|
||||
The choice persists across launches.
|
||||
|
||||
**Responsive columns** (grid mode), tuned so cards stay large enough to read
|
||||
cover art on a phone and don't become postage stamps on a desktop:
|
||||
|
||||
| Breakpoint | Columns |
|
||||
|------------|---------|
|
||||
| base (phone) | 2 |
|
||||
| sm | 3 |
|
||||
| md | 4 |
|
||||
| lg | 5 |
|
||||
| xl | 6 |
|
||||
|
||||
### 5A.3 What a card shows
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ │ ← cover art (aspect per §5A.1)
|
||||
│ artwork │ • progress bar overlay if partially played
|
||||
│ │ • watched/played check if complete
|
||||
│ [▶] │ • play affordance on hover/focus
|
||||
└─────────────┘
|
||||
Primary line ← title, truncated to one line
|
||||
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||||
```
|
||||
|
||||
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
|
||||
grows to fit its title breaks grid alignment and makes scanning harder.
|
||||
- **Progress and watched state live on the artwork**, not in the text — they
|
||||
must be readable while scanning, without reading.
|
||||
- **Hover/focus reveals play**, so a card is both a navigation target and a
|
||||
playback target without a second control competing for space at rest.
|
||||
|
||||
### 5A.4 Known deviations
|
||||
|
||||
These are places the implementation currently diverges from the rules above.
|
||||
They are recorded here so the gap is explicit rather than mistaken for intent.
|
||||
|
||||
- **The view toggle is discoverable only on a browse page.** The preference is
|
||||
already global and persisted, but the only control that sets it is the pair
|
||||
of icon buttons in a library page header. Settings has no display section, so
|
||||
there is nowhere to look for it. *(UR-029)*
|
||||
|
||||
---
|
||||
|
||||
## 5B. Video Detail Page Composition
|
||||
|
||||
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
|
||||
surface renders is decided by item type plus the `?episode=` query param, and
|
||||
**section order is part of the spec** — it is what makes "keep watching this
|
||||
show" the path of least resistance.
|
||||
|
||||
### 5B.1 Which surface renders
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Nav[Navigate to /library/[id]] --> Type{Item type}
|
||||
|
||||
Type -->|Person| Person[PersonDetailView]
|
||||
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||||
Type -->|Series| Ep{?episode= param<br/>present?}
|
||||
|
||||
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||||
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||||
|
||||
Focus -->|Back to series| Series
|
||||
Series -->|Click episode| Focus
|
||||
```
|
||||
|
||||
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||
episode anywhere navigates to `/library/<seriesId>?episode=<episodeId>`, so the
|
||||
episode is always shown in the context of its series and the series' full
|
||||
episode list is already loaded.
|
||||
|
||||
### 5B.2 Episode Focus View — section order
|
||||
|
||||
**The next episodes appear directly below the current episode, above cast and
|
||||
similar shows.** Nothing may be inserted between the episode hero and the
|
||||
episode strip.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ episode backdrop │ │
|
||||
│ │ Series Name │ │ ← 1. HERO
|
||||
│ │ Episode Title │ │
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||||
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||||
│ │ ││NOW ││ ││ │ │
|
||||
│ └──────┘└──────┘└──────┘└──────┘ │
|
||||
│ │
|
||||
│ Cast │ ← 3. CAST
|
||||
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||||
│ │
|
||||
│ More Like This │ ← 4. SIMILAR
|
||||
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules for the episode strip:**
|
||||
|
||||
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
|
||||
between the current episode and every other section; cast and related
|
||||
content are *below* it, never above.
|
||||
- **Window, not full list.** The strip shows a window around the current
|
||||
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||||
visible without scrolling, and earlier ones remain reachable by scrolling
|
||||
left. It is horizontally scrollable, not a wrapped grid.
|
||||
- **Forward bias.** More episodes are shown *after* the current one than
|
||||
before it: the dominant intent on this screen is "watch the next one."
|
||||
- **The current episode is present and marked.** It renders in-strip with a
|
||||
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||||
user's position in the season rather than being hidden.
|
||||
- **Cross-season continuity.** The window spans the whole series in episode
|
||||
order, so the strip runs past a season boundary into the next season's first
|
||||
episodes rather than dead-ending at the end of a season.
|
||||
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
|
||||
progress bar when partially watched, and a watched checkmark when complete.
|
||||
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
|
||||
not start playback. Playback starts only from the hero's Play button.
|
||||
|
||||
### 5B.3 Movie detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download / Favorite)
|
||||
→ Crew links (Directed by / Written by / Music by)
|
||||
→ Genre tags
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
A movie has no continuation set, so cast follows the hero directly.
|
||||
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
determines what the search covers. A search begun inside the Music library
|
||||
searches music. A search begun from Home or the top-level library page searches
|
||||
everything. The scope is always shown, and always overridable.
|
||||
|
||||
### 6.1 Scope is inherited from context
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start[User starts a search] --> Where{Where from?}
|
||||
|
||||
Where -->|Home (/)| All[Scope: All]
|
||||
Where -->|Library root (/library)| All
|
||||
Where -->|Search tab| All
|
||||
Where -->|Inside Music| Music[Scope: Music]
|
||||
Where -->|Inside Movies| Movies[Scope: Movies]
|
||||
Where -->|Inside TV| TV[Scope: TV]
|
||||
|
||||
All --> Chips[Filter chips shown<br/>All chip selected]
|
||||
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||||
Movies --> Chips2
|
||||
TV --> Chips2
|
||||
|
||||
Chips --> Results[Results, grouped by type]
|
||||
Chips2 --> Results
|
||||
|
||||
Results --> Change{User taps a chip}
|
||||
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||||
Rescope --> Results
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Context sets the *initial* chip, never a locked filter.** Entering search
|
||||
from TV preselects the TV chip; the user can tap "All" to widen without
|
||||
retyping the query. Scope is a starting point, not a cage.
|
||||
- **Home, `/library`, and the search tab all start at "All".** These are the
|
||||
places a user has expressed no narrower intent.
|
||||
- **Changing scope preserves the query** and re-runs the search. Changing the
|
||||
query preserves the scope.
|
||||
- **Scope maps to item types**, resolved at the point of search:
|
||||
|
||||
| Chip | `includeItemTypes` |
|
||||
|------|--------------------|
|
||||
| All | *(unset — every type)* |
|
||||
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
|
||||
| Movies | `Movie` |
|
||||
| TV | `Series`, `Episode` |
|
||||
|
||||
- **Chips render under the search bar**, on both the dedicated search page and
|
||||
the in-library header search. They are horizontally scrollable if they
|
||||
overflow, never wrapped onto a second row.
|
||||
|
||||
### 6.2 Search page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ │
|
||||
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||||
│ │
|
||||
│ Songs ──────────────────────────── │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ │
|
||||
│ Albums ─────────────────────────── │
|
||||
│ [Album Cover] Album Title │
|
||||
│ [Album Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ [Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ │
|
||||
│ Artists ────────────────────────── │
|
||||
│ [Photo] Artist Name │
|
||||
│ See all (5) │
|
||||
│ ( Photo ) Artist Name │
|
||||
│ See all (5) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Results stay **grouped by type** even when a scope is selected — a Music
|
||||
search still separates Songs / Albums / Artists.
|
||||
- Each group shows a bounded preview with a **See all (n)** affordance rather
|
||||
than an unbounded list, so no single type can bury the others.
|
||||
- Live search is **debounced** as the user types; a query that becomes empty
|
||||
clears results rather than searching for the empty string.
|
||||
|
||||
### 6.3 Result group order is user-configurable
|
||||
|
||||
Which *kind* of thing a user is usually searching for is personal: a
|
||||
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||||
than guessing, the group order is a setting.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||||
|
||||
List --> Drag[User drags a group up or down]
|
||||
Drag --> Persist[Order persisted]
|
||||
|
||||
Persist --> Render[Rendering a result set]
|
||||
Scope[Active scope chip §6.1] --> Render
|
||||
|
||||
Render --> Filter[1 - Drop groups outside the active scope]
|
||||
Filter --> Sort[2 - Sort remaining groups by user order]
|
||||
Sort --> Prune[3 - Omit groups with no results]
|
||||
Prune --> Show[Render]
|
||||
```
|
||||
|
||||
**Scope and order compose — they are two independent axes.** The scope chip
|
||||
decides *which* groups are eligible; the settings list decides *what sequence*
|
||||
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||||
renumbered per scope:
|
||||
|
||||
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
|
||||
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
|
||||
groups keep their relative order.
|
||||
- Scope **All** with the same setting renders all five in exactly that order.
|
||||
- **Changing scope never rewrites the saved order.** A user who narrows to
|
||||
Music and back to All sees their original arrangement intact.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Drag and drop to reorder**, in a settings list showing every result group
|
||||
(Songs, Albums, Artists, Movies, TV Shows).
|
||||
- **The order applies to grouped results everywhere** — the search page and
|
||||
the in-library header search alike.
|
||||
- **Order is presentation-only.** It never changes which results are returned
|
||||
or how they are ranked *within* a group, only the sequence groups appear in.
|
||||
- **Empty groups are skipped, not gapped.** A group with no results is omitted
|
||||
entirely; it does not reserve space or leave a stray heading.
|
||||
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
|
||||
so the setting is an adjustment, never a prerequisite.
|
||||
- **Keyboard/accessible reordering must exist** alongside dragging — a
|
||||
drag-only control is unusable with a screen reader or without a pointer.
|
||||
|
||||
### 6.4 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Scope is not implemented.** The in-library header search calls the same
|
||||
unscoped query as the global search page, so searching inside TV returns
|
||||
music. The backend already accepts `includeItemTypes` on both the online and
|
||||
offline paths, and the per-page list search already uses it — only the global
|
||||
path ignores it. *(UR-049)*
|
||||
- **Filter chips do not exist** on either search surface. *(UR-049)*
|
||||
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
|
||||
with no setting. *(UR-050)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Download Flows
|
||||
@@ -532,68 +883,166 @@ States:
|
||||
5. [⏸] Paused - Yellow pause icon
|
||||
```
|
||||
|
||||
### 7.2 Managing Downloads Page
|
||||
### 7.2 Downloads = a browsable offline library, not a flat list
|
||||
|
||||
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
|
||||
*the library, filtered to what's on the device*. A user who has downloaded three
|
||||
seasons of a show and two albums thinks in terms of shows and albums, not
|
||||
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||||
**reuses the library browse screens**, scoped to downloaded content, and keeps
|
||||
the transfer-progress list as a secondary "Transfers" view for the *act* of
|
||||
downloading.
|
||||
|
||||
This splits one overloaded page into two clear jobs:
|
||||
|
||||
| Surface | Answers | Reuses |
|
||||
|---------|---------|--------|
|
||||
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
|
||||
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User] --> NavChoice{Navigation Path}
|
||||
Nav[Open Downloads] --> Downloads[/downloads]
|
||||
|
||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||
Downloads --> View{View}
|
||||
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||
View -->|Transfers| Activity[Transfer activity list]
|
||||
|
||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||
HeaderIcon --> DownloadsPage
|
||||
TypeURL --> DownloadsPage
|
||||
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||
Grid --> Detail[Detail page<br/>same as online]
|
||||
Detail --> Play[Play from local file]
|
||||
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||||
|
||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||
|
||||
ShowTabs --> ActiveTab{Active Tab}
|
||||
|
||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||
|
||||
ShowActive --> UserAction1{User Action}
|
||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||
|
||||
ShowCompleted --> UserAction2{User Action}
|
||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||
```
|
||||
|
||||
**Navigation to Downloads:**
|
||||
- **Desktop:** Click "Downloads" link in header navigation
|
||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||
- **Direct:** Navigate to `/downloads` route
|
||||
**Why reuse the library screens (not a bespoke list):**
|
||||
|
||||
- **One mental model.** Browsing offline should feel identical to browsing
|
||||
online — same grids, same card shapes, same detail pages, same play action.
|
||||
The only difference is *what's present*, not *how it looks*.
|
||||
- **It already works in the backend.** The offline repository's `get_items`
|
||||
already returns downloaded items **plus** their containers (an album with any
|
||||
downloaded track, a series/season with any downloaded episode). That is a
|
||||
browsable tree today — see §7.4.
|
||||
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
|
||||
browsable library does not.
|
||||
|
||||
### 7.3 The Downloaded browse surface
|
||||
|
||||
**Downloads Page Layout:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [←] Downloads │
|
||||
│ │
|
||||
│ [Active (3)] [Completed (12)] │
|
||||
│ │
|
||||
│ ─ Downloading ──────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [████████░░] 80% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [██░░░░░░░░] 20% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ ─ Queued ───────────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ Waiting... │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Downloads │
|
||||
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||
│ │
|
||||
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||
│ │
|
||||
│ Music │ ← only libraries that
|
||||
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||
│ │alb ││alb ││art │ │
|
||||
│ └────┘└────┘└────┘ │
|
||||
│ │
|
||||
│ TV │
|
||||
│ ┌────┐┌────┐ │
|
||||
│ │show││show│ │
|
||||
│ └────┘└────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
|
||||
music is downloaded, only Music appears.
|
||||
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
|
||||
browse is the same components with an offline-scoped data source, never a
|
||||
parallel re-implementation.
|
||||
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
|
||||
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
|
||||
- **Disk usage is shown where the user already looks**, in familiar units — see
|
||||
§7.3.1.
|
||||
- **Play always plays the local file** here; nothing on this surface streams.
|
||||
- **Remove is available at every level** — item, album/season, series — and
|
||||
states clearly what it frees. Removing the last downloaded child of a
|
||||
container removes the container from the browse.
|
||||
- **This surface works identically online and offline.** It is "what's on the
|
||||
device," a question whose answer does not depend on connectivity. It must not
|
||||
wait for, or be emptied by, server reachability.
|
||||
|
||||
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
|
||||
|
||||
Users want to know what each thing costs on disk, but that information has to
|
||||
feel like the storage views they already know (phone Settings → Storage, a
|
||||
file browser), not a developer's byte dump.
|
||||
|
||||
- **Size rides along with the item, on the card and the detail page** — a small
|
||||
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
|
||||
report" screen the user has to go find.
|
||||
- **Containers show their total.** A series shows the sum of its downloaded
|
||||
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||||
number a user sees on the "Breaking Bad" card is what removing it frees.
|
||||
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
|
||||
and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not
|
||||
`1,283,048,192 bytes` and not `1.28394 GB`).
|
||||
- **A single device total sits at the top** of the Downloaded surface
|
||||
(`3.4 GB on device · 12 items`) so the headline number is answered before the
|
||||
user scans. It reconciles with the sum of what's listed.
|
||||
- **Remove restates the reclaim** in the same units at the point of action
|
||||
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||||
legible exactly when the user decides.
|
||||
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
|
||||
what to clear) but is not required for v1.
|
||||
|
||||
The bytes-on-disk per item are a backend fact (the download manager writes the
|
||||
files and can stat them); this is a display and aggregation task, not new
|
||||
tracking. See §7.7 deviations for what's missing today.
|
||||
|
||||
### 7.4 Transfers (activity) view
|
||||
|
||||
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||||
It is about *transfers in flight*, so it shows only rows that are doing or
|
||||
waiting to do something:
|
||||
|
||||
- **States:** downloading (with progress), queued, paused, failed,
|
||||
waiting-for-WiFi (§7.5).
|
||||
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||||
and auto-pump are backend concerns and are not surfaced as manual controls.
|
||||
- **Completed transfers fall off this view** once done — the finished item lives
|
||||
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||||
permanent completed-list is not (that's what Downloaded is for).
|
||||
- **Empty state** points at the library: "Nothing downloading. Browse your
|
||||
library and tap download to save media for offline."
|
||||
|
||||
### 7.5 Navigation & entry points
|
||||
|
||||
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||||
link/icon → `/downloads`.
|
||||
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
|
||||
and should draw attention (badge/count) only while transfers are active.
|
||||
- Initiating a download is unchanged (§7.1): the download button lives on
|
||||
item/album/series detail pages. The Downloads page manages and browses; it is
|
||||
not where you start a download.
|
||||
|
||||
### 7.7 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
|
||||
row per individual transfer with no browsing, grouping, or reuse of the
|
||||
library screens. Completed downloads never collapse into their album/series.
|
||||
*(UR-055)*
|
||||
- **No offline-scoped browse entry point exists in the client.** All browsing
|
||||
goes through the hybrid repository, which merges cache **and** server; there is
|
||||
no way to ask for "downloaded content only" as a browse surface. The offline
|
||||
repository supports it (§7.2) but is not reachable independently. *(UR-055,
|
||||
DR-082)*
|
||||
- **The "on device" storage summary and per-container remove** are absent from
|
||||
the completed list. *(UR-055, UR-056)*
|
||||
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
|
||||
no size; there is no device total, no container subtotal, and Remove does not
|
||||
state what it frees. *(UR-056)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings & Account Flows
|
||||
@@ -627,6 +1076,13 @@ flowchart TB
|
||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||
- **Direct:** Navigate to `/settings` route
|
||||
|
||||
**Settings apply instantly.** Every control on the Settings page persists the
|
||||
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||||
slider writes that setting immediately. There is **no "Save" button** and no
|
||||
save/dirty state to reason about; leaving the page never risks losing a change.
|
||||
Sliders update their live readout while dragging but only persist on release
|
||||
(`change`, not each `input` tick) to avoid flooding the backend.
|
||||
|
||||
### 8.2 Logout Flow
|
||||
|
||||
```mermaid
|
||||
@@ -690,24 +1146,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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.16",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -17,6 +17,7 @@
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:all": "./scripts/test-all.sh",
|
||||
"test:rust": "./scripts/test-rust.sh",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
@@ -28,7 +29,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": {
|
||||
|
||||
@@ -44,6 +44,9 @@ bun run build
|
||||
|
||||
# Step 2: Build Android APK
|
||||
if [ "$BUILD_TYPE" = "release" ]; then
|
||||
# Configure release signing from .env (single source of truth). Must run
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
echo "📦 Building release APK..."
|
||||
bun run tauri android build --apk true
|
||||
else
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
||||
# that motivated this check.
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
)
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
[[ "$file" == "$allowed" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
file="${line%%:*}"
|
||||
case "$file" in
|
||||
*.test.*) continue ;;
|
||||
esac
|
||||
if is_allowed "$file"; then
|
||||
echo " ⏭️ allowlisted: $line"
|
||||
continue
|
||||
fi
|
||||
violations+="$line"$'\n'
|
||||
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
||||
echo " the real gate for subtler leaks.)"
|
||||
@@ -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,34 @@ 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
|
||||
# ic_launcher_monochrome.png would just be dead weight.
|
||||
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
|
||||
|
||||
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
|
||||
# as API-qualified VECTOR drawables:
|
||||
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
|
||||
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
|
||||
# Because drawable-v24 is a more specific match than our unqualified
|
||||
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
|
||||
# the green square robot instead of our jellyfish. Remove them so the
|
||||
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
|
||||
# to the real committed PNGs.
|
||||
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
|
||||
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
fi
|
||||
|
||||
echo "✓ Android sources synced successfully"
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
|
||||
#
|
||||
# .env is the single source of truth for local release signing. `tauri android
|
||||
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
|
||||
# from .env before every release build (this is the local mirror of what the CI
|
||||
# workflow does from Gitea secrets).
|
||||
#
|
||||
# Required .env vars:
|
||||
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
|
||||
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "❌ $ENV_FILE not found — cannot configure release signing." >&2
|
||||
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
|
||||
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load .env without leaking it into the caller's environment beyond what we need.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
|
||||
|
||||
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$PROPS")"
|
||||
umask 077
|
||||
cat > "$PROPS" <<EOF
|
||||
storeFile=$ANDROID_KEYSTORE_FILE
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
EOF
|
||||
|
||||
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
|
||||
@@ -9,6 +9,16 @@
|
||||
-keep class com.dtourolle.jellytau.player.** { *; }
|
||||
-keep class com.dtourolle.jellytau.security.** { *; }
|
||||
|
||||
# Picture-in-picture is driven from the WebView through an
|
||||
# @JavascriptInterface bridge, so the only references to these methods live
|
||||
# in JavaScript. R8 sees them as unused and would strip them, silently
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
|
||||
@@ -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,69 @@
|
||||
<?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" />
|
||||
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<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,80 @@ 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 onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
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 +157,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 +173,81 @@ 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")
|
||||
|
||||
// Network transport reporting for the WiFi-only download gate (UR-053).
|
||||
// The frontend polls these on demand and re-pumps the download queue when
|
||||
// the 'jellytau-network-changed' event fires.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
|
||||
@JavascriptInterface
|
||||
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
|
||||
|
||||
/** Whether the active network is unmetered. */
|
||||
@JavascriptInterface
|
||||
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
|
||||
|
||||
/** Whether downloads may run given the wifi-only preference. */
|
||||
@JavascriptInterface
|
||||
fun isAcceptable(wifiOnly: Boolean): Boolean =
|
||||
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
|
||||
|
||||
/** Whether native network detection is available at all (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
@@ -93,7 +271,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,152 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
/**
|
||||
* Reports the *kind* of network the device is on, so downloads can be gated on
|
||||
* "unmetered only" (the WiFi-only setting).
|
||||
*
|
||||
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
|
||||
* answers a different question: whether the Jellyfin *server* is reachable,
|
||||
* derived from real request outcomes. Reachability and transport type are
|
||||
* orthogonal — you can be on WiFi with a dead server, or on cellular with a
|
||||
* perfectly reachable one.
|
||||
*
|
||||
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
|
||||
* and we report UNKNOWN (which the gate treats as "not acceptable" when
|
||||
* wifi-only is on, failing closed rather than burning mobile data).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
object NetworkTypeMonitor {
|
||||
private const val TAG = "NetworkTypeMonitor"
|
||||
|
||||
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
|
||||
const val TYPE_NONE = "none"
|
||||
const val TYPE_WIFI = "wifi"
|
||||
const val TYPE_ETHERNET = "ethernet"
|
||||
const val TYPE_CELLULAR = "cellular"
|
||||
const val TYPE_OTHER = "other"
|
||||
const val TYPE_UNKNOWN = "unknown"
|
||||
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** Invoked on any network change; set by [startWatching]. */
|
||||
@Volatile
|
||||
private var onChange: (() -> Unit)? = null
|
||||
|
||||
private fun connectivityManager(context: Context): ConnectivityManager? =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
/**
|
||||
* Current transport type of the active network.
|
||||
*
|
||||
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
|
||||
* distinguish "definitely offline" from "couldn't tell".
|
||||
*/
|
||||
fun currentType(context: Context): String {
|
||||
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
|
||||
val network = cm.activeNetwork ?: return TYPE_NONE
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
|
||||
|
||||
return when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
|
||||
else -> TYPE_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active network is unmetered.
|
||||
*
|
||||
* This is the bit that actually matters for the WiFi-only gate: a phone
|
||||
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
|
||||
* cellular data the setting exists to protect. Checking NOT_METERED rather
|
||||
* than the transport alone means tethering doesn't quietly burn a data plan.
|
||||
*/
|
||||
fun isUnmetered(context: Context): Boolean {
|
||||
val cm = connectivityManager(context) ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads may run right now given the wifi-only preference.
|
||||
*
|
||||
* Ethernet counts as acceptable (it is unmetered in practice and is what
|
||||
* Android TV devices use). Cellular never does. When wifi-only is off this is
|
||||
* always true — the gate simply isn't engaged.
|
||||
*/
|
||||
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
|
||||
if (!wifiOnly) return true
|
||||
val type = currentType(context)
|
||||
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
|
||||
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
|
||||
return isUnmetered(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the network changes, so a blocked
|
||||
* download queue can be re-pumped the moment an acceptable network appears.
|
||||
* Without this the queue would stall until some unrelated event pumped it.
|
||||
*
|
||||
* Idempotent: a second call replaces the previous callback.
|
||||
*/
|
||||
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
|
||||
val cm = connectivityManager(context) ?: run {
|
||||
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
|
||||
return
|
||||
}
|
||||
|
||||
stopWatching(context)
|
||||
onChange = onNetworkChanged
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
android.util.Log.d(TAG, "Network available")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
android.util.Log.d(TAG, "Network lost")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
// Fires when e.g. metered-ness flips without the network itself changing.
|
||||
onChange?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cm.registerNetworkCallback(request, cb)
|
||||
callback = cb
|
||||
android.util.Log.d(TAG, "Network callback registered")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister the network callback, if one is active. */
|
||||
fun stopWatching(context: Context) {
|
||||
val cb = callback ?: return
|
||||
val cm = connectivityManager(context)
|
||||
try {
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
callback = null
|
||||
onChange = null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+45
-5
@@ -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,16 @@ 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
|
||||
// before) enableRemoteVolume(); this keeps the session routed to the
|
||||
// remote (absolute) volume slider instead of the local media stream.
|
||||
if (isRemoteVolumeEnabled) {
|
||||
volumeProvider?.let { session.setPlaybackToRemote(it) }
|
||||
}
|
||||
|
||||
// Update the notification
|
||||
updateNotification(title, artist, isPlaying)
|
||||
@@ -314,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)
|
||||
@@ -326,8 +357,17 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
*
|
||||
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||
* Android does not extrapolate the position past a paused track.
|
||||
*
|
||||
* While remote volume control is enabled (casting), the state is forced to
|
||||
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
|
||||
* (absolute) volume slider for a session that is actively playing; if a
|
||||
* periodic metadata/position push reports paused (e.g. before the remote
|
||||
* session has actually started), reporting STATE_PAUSED here makes the
|
||||
* system tear down the remote slider set up by setPlaybackToRemote() and
|
||||
* fall back to the local media-stream volume.
|
||||
*/
|
||||
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||
val playing = isPlaying || isRemoteVolumeEnabled
|
||||
return PlaybackStateCompat.Builder()
|
||||
.setActions(
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
@@ -338,9 +378,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
PlaybackStateCompat.ACTION_SEEK_TO
|
||||
)
|
||||
.setState(
|
||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
position,
|
||||
if (isPlaying) 1.0f else 0.0f
|
||||
if (playing) 1.0f else 0.0f
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
+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_with_retry::<PublicSystemInfo>(&endpoint).await {
|
||||
match self
|
||||
.http_client
|
||||
.get_json_fast::<PublicSystemInfo>(&endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||
log::info!(
|
||||
"[AuthManager] Connected to server: {} ({})",
|
||||
info.server_name,
|
||||
info.version
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.json(&serde_json::json!({
|
||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||
let auth_response: AuthenticateByNameResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||
log::info!(
|
||||
"[AuthManager] Login successful for user: {} ({})",
|
||||
auth_response.user.name,
|
||||
auth_response.user.id
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -243,13 +269,19 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.get(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||
format!("Session verification failed: {}", e)
|
||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Mark server as unreachable for auth errors
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
let monitor = monitor.lock().await;
|
||||
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||
monitor
|
||||
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let user_response: JellyfinUser = response.json().await
|
||||
let user_response: JellyfinUser = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||
log::info!(
|
||||
"[AuthManager] Session verified successfully for: {}",
|
||||
user_response.name
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -306,7 +348,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
@@ -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,17 +31,18 @@ pub async fn auth_initialize(
|
||||
log::info!("[AuthManager] Restoring session from storage...");
|
||||
|
||||
// Use the existing storage_get_active_session function
|
||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let active_session =
|
||||
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||
Ok(Some(session)) => session,
|
||||
Ok(None) => {
|
||||
log::info!("[AuthManager] No active session in storage");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Create session object from active session with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||
@@ -56,7 +61,11 @@ pub async fn auth_initialize(
|
||||
// Store in AuthManager
|
||||
auth_manager.0.set_session(Some(session.clone())).await;
|
||||
|
||||
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||
log::info!(
|
||||
"[AuthManager] Session restored for user: {} with normalized URL: {}",
|
||||
session.username,
|
||||
session.server_url
|
||||
);
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(&server_url, &username, &password, &device_id)
|
||||
.await?;
|
||||
|
||||
// Create session from auth result with normalized URL
|
||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
||||
device_id: String,
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<bool, String> {
|
||||
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||
match auth_manager
|
||||
.0
|
||||
.verify_session(&server_url, &user_id, &access_token, &device_id)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => {
|
||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
||||
drop(verifier_guard);
|
||||
|
||||
// Call Jellyfin logout endpoint
|
||||
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||
auth_manager
|
||||
.0
|
||||
.logout(&server_url, &access_token, &device_id)
|
||||
.await?;
|
||||
|
||||
// Clear session
|
||||
auth_manager.0.set_session(None).await;
|
||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
||||
auth_manager: State<'_, AuthManagerWrapper>,
|
||||
) -> Result<AuthResult, String> {
|
||||
// Get current session to extract server_url and username
|
||||
let session = auth_manager.0.get_session().await
|
||||
let session = auth_manager
|
||||
.0
|
||||
.get_session()
|
||||
.await
|
||||
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||
|
||||
// Re-login with stored credentials
|
||||
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||
let result = auth_manager
|
||||
.0
|
||||
.login(
|
||||
&session.server_url,
|
||||
&session.username,
|
||||
&password,
|
||||
&device_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update session with new token
|
||||
let updated_session = Session {
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! 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:
|
||||
//!
|
||||
//! - [`sync_full_catalog`] walks every library while online and persists all
|
||||
//! items to the offline cache so the whole catalog is browsable (greyed out)
|
||||
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
|
||||
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
|
||||
//! what `get_items` branch 3 serves offline).
|
||||
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
|
||||
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::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};
|
||||
|
||||
/// app_settings key holding the RFC-3339 timestamp of the last successful
|
||||
/// full-catalog sync.
|
||||
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||
|
||||
/// Item types worth caching for offline browsing: containers the library
|
||||
/// landing pages render plus the playable leaves users queue for download.
|
||||
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||
"MusicAlbum",
|
||||
"Movie",
|
||||
"Series",
|
||||
"Season",
|
||||
"Episode",
|
||||
"Audio",
|
||||
"BoxSet",
|
||||
];
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogSyncResult {
|
||||
/// Total items persisted to the offline cache across all libraries.
|
||||
pub items_cached: usize,
|
||||
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||
pub libraries_failed: usize,
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CatalogSyncStatus {
|
||||
/// RFC-3339 timestamp of the last successful sync, if any.
|
||||
pub last_synced_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Walk every library on the server and persist all items to the offline cache
|
||||
/// so the full catalog is browsable offline (greyed out when not downloaded).
|
||||
///
|
||||
/// Best-effort: a library that fails to fetch is counted and skipped rather than
|
||||
/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
|
||||
/// server. Uses `Recursive=true` so a single request per library returns the
|
||||
/// containers and their playable children.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn sync_full_catalog(
|
||||
repository: State<'_, RepositoryManagerWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
handle: String,
|
||||
) -> Result<CatalogSyncResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||
info!(
|
||||
"[Catalog] Full sync starting across {} libraries",
|
||||
libraries.len()
|
||||
);
|
||||
|
||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
let mut items_cached = 0usize;
|
||||
let mut libraries_failed = 0usize;
|
||||
|
||||
for library in &libraries {
|
||||
let opts = GetItemsOptions {
|
||||
recursive: Some(true),
|
||||
include_item_types: Some(include_types.clone()),
|
||||
limit: Some(100_000),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match repo.cache_items_from_server(&library.id, Some(opts)).await {
|
||||
Ok(items) => {
|
||||
info!(
|
||||
"[Catalog] Cached {} items from library '{}'",
|
||||
items.len(),
|
||||
library.name
|
||||
);
|
||||
items_cached += items.len();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Catalog] Failed to sync library '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
libraries_failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let upsert = Query::with_params(
|
||||
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||
vec![
|
||||
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
|
||||
QueryParam::String(now),
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(upsert).await {
|
||||
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||
items_cached, libraries_failed
|
||||
);
|
||||
|
||||
Ok(CatalogSyncResult {
|
||||
items_cached,
|
||||
libraries_failed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||
/// to trigger a fresh sync.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn catalog_sync_status(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
) -> Result<CatalogSyncStatus, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let query = Query::with_params(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||
);
|
||||
let last_synced_at: Option<String> = db_service
|
||||
.query_optional(query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(CatalogSyncStatus { last_synced_at })
|
||||
}
|
||||
|
||||
/// Control whether offline library queries reveal the full synced catalog
|
||||
/// (greyed-out, non-downloaded media) or only downloaded/local media.
|
||||
///
|
||||
/// The frontend calls this from the "Show all server media" toggle: pass `true`
|
||||
/// when online, or when offline with the toggle on; pass `false` when offline
|
||||
/// with the toggle off so library pages show downloaded media only. Fixes the
|
||||
/// bug where offline library pages showed every server item regardless of the
|
||||
/// toggle.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_show_server_catalog(show: bool) {
|
||||
crate::repository::offline::set_include_catalog_browse(show);
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResumeQueuedResult {
|
||||
/// Rows whose stream URL was resolved and are now pump-eligible.
|
||||
pub resolved: usize,
|
||||
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||
target_dir: &str,
|
||||
resolve: F,
|
||||
) -> Result<ResumeQueuedResult, String>
|
||||
where
|
||||
F: Fn(String, String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Option<String>>,
|
||||
{
|
||||
let rows_query = Query::new(
|
||||
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||
FROM downloads
|
||||
WHERE status = 'pending' AND stream_url IS NULL",
|
||||
);
|
||||
let rows: Vec<(i64, String, String, String)> = db_service
|
||||
.query_many(rows_query, |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(ResumeQueuedResult {
|
||||
resolved: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||
rows.len()
|
||||
);
|
||||
|
||||
let mut resolved = 0usize;
|
||||
let mut failed = 0usize;
|
||||
|
||||
for (download_id, item_id, media_type, quality) in rows {
|
||||
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
||||
// concurrent resolver doesn't clobber an already-started row.
|
||||
let update = Query::with_params(
|
||||
"UPDATE downloads SET stream_url = ?, target_dir = ?
|
||||
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||
vec![
|
||||
QueryParam::String(stream_url),
|
||||
QueryParam::String(target_dir.to_string()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
match db_service.execute(update).await {
|
||||
Ok(n) if n > 0 => resolved += 1,
|
||||
Ok(_) => {} // already resolved by someone else; not a failure
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Catalog] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResumeQueuedResult { resolved, failed })
|
||||
}
|
||||
|
||||
/// Resolve the stream URL for every download row that was queued while offline
|
||||
/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
|
||||
/// start. Call this on reconnect.
|
||||
///
|
||||
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
|
||||
/// 'video') via the pure `get_video_download_url` builder using the row's stored
|
||||
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
|
||||
/// be resolved are left pending (they retry on the next reconnect).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn resume_queued_downloads(
|
||||
repository: State<'_, RepositoryManagerWrapper>,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
app: tauri::AppHandle,
|
||||
handle: String,
|
||||
) -> Result<ResumeQueuedResult, String> {
|
||||
use crate::repository::MediaRepository;
|
||||
|
||||
use crate::repository::HybridRepository;
|
||||
|
||||
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||
|
||||
// The pump needs a target_dir; use the same storage root the other download
|
||||
// paths use (the database's parent directory — see `storage_get_path`).
|
||||
let (db_service, target_dir) = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
let target_dir = database
|
||||
.path()
|
||||
.parent()
|
||||
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
(Arc::new(database.service()), target_dir)
|
||||
};
|
||||
|
||||
// Recover stale downloads: rows left in 'downloading' when the app was killed
|
||||
// mid-transfer are orphaned — nothing ever restarts them, so they show as
|
||||
// permanently "downloading". Reset them to 'pending' and clear the stale
|
||||
// stream_url so they get re-resolved and restarted from scratch below.
|
||||
let recover_query = Query::new(
|
||||
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
|
||||
bytes_downloaded = 0, started_at = NULL \
|
||||
WHERE status = 'downloading'",
|
||||
);
|
||||
match db_service.execute(recover_query).await {
|
||||
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||
}
|
||||
|
||||
// Resolve each row's URL against the (now reachable) repository.
|
||||
let repo_for_resolve = Arc::clone(&repo);
|
||||
let outcome = resolve_pending_download_urls(
|
||||
&db_service,
|
||||
&target_dir,
|
||||
move |item_id: String, media_type: String, quality: String| {
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let ResumeQueuedResult { resolved, failed } = outcome;
|
||||
|
||||
// Kick the pump so the newly-resolved rows actually start.
|
||||
if resolved > 0 {
|
||||
let active_downloads = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||
resolved, failed
|
||||
);
|
||||
|
||||
Ok(ResumeQueuedResult { resolved, failed })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_db() -> Arc<RusqliteService> {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
stream_url TEXT,
|
||||
target_dir TEXT,
|
||||
media_type TEXT,
|
||||
quality_preset TEXT
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||
}
|
||||
|
||||
async fn insert_download(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
status: &str,
|
||||
stream_url: Option<&str>,
|
||||
media_type: Option<&str>,
|
||||
) {
|
||||
let q = Query::with_params(
|
||||
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(status.to_string()),
|
||||
stream_url
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
media_type
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
db.execute(q).await.unwrap();
|
||||
}
|
||||
|
||||
async fn get_row(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
) -> (String, Option<String>, Option<String>) {
|
||||
let q = Query::with_params(
|
||||
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
);
|
||||
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||
let db = test_db();
|
||||
// A row queued offline: pending with no URL yet.
|
||||
insert_download(&db, "queued-1", "pending", None, None).await;
|
||||
// An already-resolved pending row: must NOT be touched.
|
||||
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
|
||||
// The offline-queued row now has a URL + target dir and stays pending.
|
||||
let (status, url, target) = get_row(&db, "queued-1").await;
|
||||
assert_eq!(status, "pending");
|
||||
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
|
||||
assert_eq!(target.as_deref(), Some("/data/downloads"));
|
||||
|
||||
// The already-resolved row is unchanged (not re-resolved).
|
||||
let (_s, url2, _t) = get_row(&db, "already").await;
|
||||
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "bad", "pending", None, None).await;
|
||||
|
||||
// Resolver returns None (e.g. server lookup failed).
|
||||
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 0);
|
||||
assert_eq!(out.failed, 1);
|
||||
|
||||
// Still pending with no URL, so a later reconnect can retry it.
|
||||
let (status, url, _t) = get_row(&db, "bad").await;
|
||||
assert_eq!(status, "pending");
|
||||
assert_eq!(url, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn video_rows_use_media_type_in_resolver() {
|
||||
let db = test_db();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
|
||||
#[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::network::{NetworkState, NetworkStateHandle, NetworkType};
|
||||
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).
|
||||
@@ -21,6 +22,80 @@ pub use smart_cache::*;
|
||||
/// Wrapper for DownloadManager to be used as Tauri state
|
||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
||||
|
||||
/// Wrapper for the current network transport, used by the WiFi-only gate.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub struct NetworkStateWrapper(pub NetworkStateHandle);
|
||||
|
||||
/// Report the device's current network transport (Android → Rust).
|
||||
///
|
||||
/// The frontend calls this on startup and whenever the native network callback
|
||||
/// fires. Updating to an acceptable network re-pumps the download queue, so a
|
||||
/// queue parked on "waiting for WiFi" drains itself without user action.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_network_state(
|
||||
app: tauri::AppHandle,
|
||||
network: NetworkStateWrapperArg,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let new_state = NetworkState {
|
||||
network_type: network.network_type,
|
||||
unmetered: network.unmetered,
|
||||
};
|
||||
|
||||
let handle = app.state::<NetworkStateWrapper>().0.clone();
|
||||
let previous = handle.get().await;
|
||||
handle.set(new_state).await;
|
||||
|
||||
if previous != new_state {
|
||||
info!(
|
||||
"[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
|
||||
previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
|
||||
);
|
||||
}
|
||||
|
||||
// If the new network unblocks the gate, drain whatever was waiting.
|
||||
if downloads_allowed_on_current_network(&app).await {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
let active = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app.clone(), db_service, active).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Argument struct for [`set_network_state`].
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkStateWrapperArg {
|
||||
pub network_type: NetworkType,
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
/// Whether downloads are currently permitted by the WiFi-only gate.
|
||||
///
|
||||
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
|
||||
/// rather than leaving them looking silently stuck.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
Ok(downloads_allowed_on_current_network(&app).await)
|
||||
}
|
||||
|
||||
/// Download statistics computed server-side
|
||||
#[allow(dead_code)]
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -111,7 +186,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 +213,8 @@ pub async fn download_item_and_start(
|
||||
album_name,
|
||||
expected_size: None,
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Start the download immediately
|
||||
start_download(
|
||||
@@ -142,7 +224,8 @@ pub async fn download_item_and_start(
|
||||
download_id,
|
||||
stream_url,
|
||||
target_dir,
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(download_id)
|
||||
}
|
||||
@@ -156,7 +239,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 +263,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 +317,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 +391,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 +423,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 +473,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 +521,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 +537,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 +551,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 +581,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 +614,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 +670,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 +701,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 +722,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 +835,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 +856,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 +905,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 +974,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 +1011,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 +1025,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 +1055,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 +1082,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 +1122,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 +1133,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 +1197,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 +1258,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 +1271,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,6 +1288,37 @@ pub async fn enqueue_video_downloads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the current network permits downloads, given the user's WiFi-only
|
||||
/// preference.
|
||||
///
|
||||
/// Reads `wifi_only` from the SmartCache config (the single home of the
|
||||
/// setting) and checks it against the transport reported by the platform. On
|
||||
/// desktop the transport defaults to unmetered ethernet, so this is always
|
||||
/// true there.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
|
||||
let wifi_only = {
|
||||
let smart_cache = app.state::<SmartCacheWrapper>();
|
||||
let cache = match smart_cache.0.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock smart cache: {}", e);
|
||||
// Fail open: a lock problem must not silently wedge downloads.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
let network = app.state::<NetworkStateWrapper>();
|
||||
network.0.allows_download(true).await
|
||||
}
|
||||
|
||||
/// Start as many pending downloads as there are free concurrency slots.
|
||||
///
|
||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
||||
@@ -1095,6 +1333,16 @@ pub(crate) async fn pump_download_queue(
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
// WiFi-only gate (UR-053): when the user has restricted downloads to
|
||||
// unmetered networks and we're on cellular (or can't tell), leave every
|
||||
// pending row exactly as it is. They stay 'pending' and the Android
|
||||
// network callback re-pumps us as soon as an acceptable network appears.
|
||||
if !downloads_allowed_on_current_network(&app).await {
|
||||
info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
|
||||
let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_concurrent = {
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
let manager = match manager.0.lock() {
|
||||
@@ -1137,7 +1385,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 +1443,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 +1485,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,16 +1522,66 @@ 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
|
||||
// be persisted to the DB here — the frontend event handler only writes it
|
||||
// when that download happens to be loaded in its store, which is not the
|
||||
// case for auto-pumped rows (or any completion while the downloads page is
|
||||
// closed). `check_for_local_download` filters on status = 'completed', so a
|
||||
// missed write leaves finished files unrecognized: albums never show as
|
||||
// downloaded and playback never switches from the (expiring) stream to the
|
||||
// local file, cutting tracks off mid-play.
|
||||
let db_service = {
|
||||
let db = app.state::<DatabaseWrapper>();
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[pump] Failed to lock database after download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(res) => {
|
||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||
info!(
|
||||
"Download completed successfully: {} bytes",
|
||||
res.bytes_downloaded
|
||||
);
|
||||
let file_path = target_path.to_string_lossy().to_string();
|
||||
|
||||
let update = Query::with_params(
|
||||
"UPDATE downloads SET status = 'completed', progress = 1.0, \
|
||||
bytes_downloaded = ?, file_size = ?, file_path = ?, \
|
||||
completed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||
QueryParam::String(file_path.clone()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update).await {
|
||||
error!(
|
||||
"[pump] Failed to persist completed status for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
|
||||
let completed_event = DownloadEvent::Completed {
|
||||
download_id,
|
||||
item_id,
|
||||
file_path: target_path.to_string_lossy().to_string(),
|
||||
file_path,
|
||||
};
|
||||
match app.emit("download-event", completed_event) {
|
||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||
@@ -1283,6 +1590,21 @@ fn spawn_download_worker(
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Download failed: {:?}", e);
|
||||
|
||||
let update = Query::with_params(
|
||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||
vec![
|
||||
QueryParam::String(e.to_string()),
|
||||
QueryParam::Int64(download_id),
|
||||
],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(update).await {
|
||||
error!(
|
||||
"[pump] Failed to persist failed status for download {}: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
}
|
||||
|
||||
let failed_event = DownloadEvent::Failed {
|
||||
download_id,
|
||||
item_id,
|
||||
@@ -1296,17 +1618,6 @@ fn spawn_download_worker(
|
||||
}
|
||||
|
||||
// A slot just freed — start the next pending download (if any).
|
||||
let db_service = {
|
||||
let db = app.state::<DatabaseWrapper>();
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
Arc::new(database.service())
|
||||
};
|
||||
pump_download_queue(app.clone(), db_service, active_downloads).await;
|
||||
});
|
||||
}
|
||||
@@ -1314,7 +1625,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())
|
||||
@@ -1338,7 +1652,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 {
|
||||
@@ -1375,8 +1692,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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1462,7 +1783,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())
|
||||
@@ -1560,7 +1884,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
|
||||
@@ -1588,6 +1915,76 @@ pub async fn delete_album_downloads(
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Remove every completed download at or under a container item.
|
||||
///
|
||||
/// Works at any level of the Downloaded browse: a leaf (removes just that
|
||||
/// download), an album/season/series (removes all downloaded descendants linked
|
||||
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
|
||||
/// on-disk files. Returns the number of downloads removed. Idempotent.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_downloads_under(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself, or any child linked to it by container id.
|
||||
const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
|
||||
AND (
|
||||
d.item_id = ?
|
||||
OR d.item_id IN (
|
||||
SELECT c.id FROM items c
|
||||
WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
|
||||
)
|
||||
)";
|
||||
|
||||
let file_query = Query::with_params(
|
||||
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let delete_query = Query::with_params(
|
||||
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id),
|
||||
],
|
||||
);
|
||||
let deleted_count = db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Download manager statistics
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||
pub struct DownloadManagerStats {
|
||||
@@ -1627,7 +2024,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 {
|
||||
@@ -1796,7 +2192,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]
|
||||
@@ -2031,7 +2430,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};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
||||
// DR-015, DR-017, DR-021, DR-028
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod connectivity;
|
||||
pub mod conversions;
|
||||
pub mod device;
|
||||
@@ -17,17 +18,18 @@ pub mod storage;
|
||||
pub mod sync;
|
||||
|
||||
pub use auth::*;
|
||||
pub use catalog::*;
|
||||
pub use connectivity::*;
|
||||
pub use conversions::*;
|
||||
pub use device::*;
|
||||
pub use download::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
|
||||
@@ -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,23 +201,31 @@ pub async fn player_add_track_by_id(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -250,18 +271,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 +303,9 @@ pub async fn player_add_tracks_by_ids(
|
||||
}
|
||||
} else {
|
||||
// Get stream URL from repository (works online/offline)
|
||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||
let stream_url = repository
|
||||
.get_audio_stream_url(&track.id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||
|
||||
MediaSource::Remote {
|
||||
@@ -290,23 +320,31 @@ pub async fn player_add_tracks_by_ids(
|
||||
id: track.id.clone(),
|
||||
title: track.name.clone(),
|
||||
name: Some(track.name.clone()), // Frontend compatibility
|
||||
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
artist: track
|
||||
.album_artist
|
||||
.clone()
|
||||
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||
album: track.album_name.clone(),
|
||||
album_name: track.album_name.clone(), // Frontend compatibility
|
||||
album_id: track.album_id.clone(),
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||
track.album_id.as_ref().map(|album_id| {
|
||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}))
|
||||
repository.get_image_url(
|
||||
album_id,
|
||||
ImageType::Primary,
|
||||
Some(ImageOptions {
|
||||
max_width: Some(300),
|
||||
tag: Some(tag),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
media_type: MediaType::Audio,
|
||||
@@ -339,7 +377,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,8 +83,13 @@ pub async fn repository_create(
|
||||
|
||||
// Create online repository wired to connectivity reporting
|
||||
debug!("[REPO] Creating online repository...");
|
||||
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
let online = OnlineRepository::new(
|
||||
Arc::new(http_client),
|
||||
server_url,
|
||||
user_id.clone(),
|
||||
access_token,
|
||||
)
|
||||
.with_connectivity(connectivity_reporter);
|
||||
debug!("[REPO] Online repository created");
|
||||
|
||||
// Create offline repository with async-safe database service
|
||||
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
|
||||
"Repository not found".to_string()
|
||||
})?;
|
||||
debug!("[REPO] Repository found, fetching libraries...");
|
||||
repo.as_ref().get_libraries()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
repo.as_ref().get_libraries().await.map_err(|e| {
|
||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||
format!("{:?}", e)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get items in a container (library, folder, album, etc.)
|
||||
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref().get_items(&parent_id, options)
|
||||
repo.as_ref()
|
||||
.get_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -183,7 +189,58 @@ 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))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: libraries that contain downloaded content.
|
||||
///
|
||||
/// Backs the Downloads "Downloaded" surface. Never merges server results and is
|
||||
/// authoritative — an empty list means nothing is downloaded.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_libraries(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<Vec<Library>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_libraries()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: items under a container that are on the device.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
parent_id: String,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content (device total, per-item/container bytes).
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_download_disk_usage(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<DownloadDiskUsage, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_download_disk_usage()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
@@ -200,7 +257,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 +273,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 +294,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 +313,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 +328,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 +343,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 +359,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 +374,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 +428,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 +440,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 +460,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 +489,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]
|
||||
@@ -486,10 +579,12 @@ pub async fn repository_report_playback_start(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let position_ticks = position_ms * 10_000;
|
||||
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))
|
||||
}
|
||||
@@ -501,10 +596,12 @@ pub async fn repository_report_playback_progress(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let position_ticks = position_ms * 10_000;
|
||||
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))
|
||||
}
|
||||
@@ -516,10 +613,13 @@ pub async fn repository_report_playback_stopped(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
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 +651,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 +668,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 +682,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 +697,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 +712,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 +728,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 +744,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,18 +482,21 @@ pub async fn storage_get_active_session(
|
||||
JOIN servers s ON u.server_id = s.id
|
||||
WHERE u.is_active = 1
|
||||
ORDER BY u.last_login_at DESC
|
||||
LIMIT 1"
|
||||
LIMIT 1",
|
||||
);
|
||||
|
||||
let result = db_service.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
let result = db_service
|
||||
.query_optional(session_query, |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match result {
|
||||
Some((user_id, username, server_id, server_url, server_name)) => {
|
||||
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
|
||||
server_name,
|
||||
access_token,
|
||||
}))
|
||||
},
|
||||
}
|
||||
Err(e) => {
|
||||
// Token not found or error - session is invalid
|
||||
warn!("Failed to get token from secure storage: {:?}", e);
|
||||
@@ -547,7 +583,9 @@ pub async fn storage_delete_user(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackProgress {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
/// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
|
||||
/// converted here so the frontend never sees ticks.
|
||||
pub position_ms: i64,
|
||||
pub is_played: bool,
|
||||
pub is_favorite: bool,
|
||||
pub play_count: i32,
|
||||
@@ -561,8 +599,11 @@ pub async fn storage_update_playback_progress(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
|
||||
// stays on this side of the boundary. 10_000 ticks = 1 ms.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -613,12 +654,14 @@ pub async fn storage_update_playback_context(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
context_type: Option<String>,
|
||||
context_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -638,8 +681,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 +768,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);
|
||||
|
||||
@@ -808,9 +864,10 @@ pub async fn storage_get_playback_progress(
|
||||
|
||||
db_service
|
||||
.query_optional(query, |row| {
|
||||
let position_ticks: i64 = row.get(1)?;
|
||||
Ok(PlaybackProgress {
|
||||
item_id: row.get(0)?,
|
||||
position_ticks: row.get(1)?,
|
||||
position_ms: position_ticks / 10_000,
|
||||
is_played: row.get::<_, i32>(2)? != 0,
|
||||
is_favorite: row.get::<_, i32>(3)? != 0,
|
||||
play_count: row.get(4)?,
|
||||
@@ -1123,7 +1180,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 +1241,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 +1263,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 +1355,6 @@ pub async fn storage_get_pending_sync_count(
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1405,7 +1503,7 @@ mod tests {
|
||||
fn test_playback_progress_serialization() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-123".to_string(),
|
||||
position_ticks: 150_000_000,
|
||||
position_ms: 150_000_000,
|
||||
is_played: true,
|
||||
is_favorite: false,
|
||||
play_count: 3,
|
||||
@@ -1422,7 +1520,7 @@ mod tests {
|
||||
fn test_playback_progress_played_status() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-456".to_string(),
|
||||
position_ticks: 0,
|
||||
position_ms: 0,
|
||||
is_played: true,
|
||||
is_favorite: true,
|
||||
play_count: 1,
|
||||
@@ -1440,7 +1538,7 @@ mod tests {
|
||||
fn test_playback_progress_not_played() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-789".to_string(),
|
||||
position_ticks: 30_000_000,
|
||||
position_ms: 30_000_000,
|
||||
is_played: false,
|
||||
is_favorite: false,
|
||||
play_count: 0,
|
||||
@@ -1510,7 +1608,7 @@ mod tests {
|
||||
fn test_playback_progress_camel_case() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "i1".to_string(),
|
||||
position_ticks: 100,
|
||||
position_ms: 100,
|
||||
is_played: true,
|
||||
is_favorite: false,
|
||||
play_count: 1,
|
||||
@@ -1519,7 +1617,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&progress).unwrap();
|
||||
// Verify camelCase serialization
|
||||
assert!(json.contains("itemId"));
|
||||
assert!(json.contains("positionTicks"));
|
||||
assert!(json.contains("positionMs"));
|
||||
assert!(json.contains("isPlayed"));
|
||||
assert!(json.contains("isFavorite"));
|
||||
assert!(json.contains("playCount"));
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Person/cast metadata cache commands.
|
||||
//!
|
||||
//! TRACES: UR-035, UR-036 | IR-023 | DR-040, DR-041
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Cached person info returned to frontend
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
|
||||
QueryParam::String(person.id),
|
||||
QueryParam::String(person.server_id),
|
||||
QueryParam::String(person.name),
|
||||
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.overview
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.primary_image_tag
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.premiere_date
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
person
|
||||
.end_date
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -117,25 +130,32 @@ pub async fn storage_save_item_people(
|
||||
let associations_clone = associations.clone();
|
||||
|
||||
// Use transaction for batch insert
|
||||
db_service.transaction(move |tx| {
|
||||
for assoc in &associations_clone {
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO item_people (
|
||||
db_service
|
||||
.transaction(move |tx| {
|
||||
for assoc in &associations_clone {
|
||||
let query = Query::with_params(
|
||||
"INSERT OR REPLACE INTO item_people (
|
||||
item_id, person_id, server_id, person_type, role, sort_order, synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||
vec![
|
||||
QueryParam::String(assoc.item_id.clone()),
|
||||
QueryParam::String(assoc.person_id.clone()),
|
||||
QueryParam::String(assoc.server_id.clone()),
|
||||
QueryParam::String(assoc.person_type.clone()),
|
||||
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||
QueryParam::Int(assoc.sort_order),
|
||||
],
|
||||
);
|
||||
tx.execute(query)?;
|
||||
}
|
||||
Ok(())
|
||||
}).await.map_err(|e| e.to_string())?;
|
||||
vec![
|
||||
QueryParam::String(assoc.item_id.clone()),
|
||||
QueryParam::String(assoc.person_id.clone()),
|
||||
QueryParam::String(assoc.server_id.clone()),
|
||||
QueryParam::String(assoc.person_type.clone()),
|
||||
assoc
|
||||
.role
|
||||
.clone()
|
||||
.map(QueryParam::String)
|
||||
.unwrap_or(QueryParam::Null),
|
||||
QueryParam::Int(assoc.sort_order),
|
||||
],
|
||||
);
|
||||
tx.execute(query)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
|
||||
|
||||
Ok(people)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Per-series preferred audio track commands.
|
||||
//!
|
||||
//! TRACES: UR-021 | DR-024
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use super::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
|
||||
/// Audio track preference for a series
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Thumbnail cache and image-URL commands.
|
||||
//!
|
||||
//! TRACES: UR-007 | JA-028 | DR-016
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::sync::Semaphore;
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tauri::State;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
@@ -11,7 +13,6 @@ use crate::repository::types::{ImageOptions, ImageType};
|
||||
use crate::repository::MediaRepository;
|
||||
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
|
||||
|
||||
|
||||
/// Get cached thumbnail path, returns None if not cached
|
||||
/// Also updates last_accessed timestamp for LRU tracking
|
||||
#[tauri::command]
|
||||
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let result = thumbnail_cache.0
|
||||
let result = thumbnail_cache
|
||||
.0
|
||||
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
||||
.await
|
||||
.map(|p| p.to_string_lossy().to_string());
|
||||
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
|
||||
let path = thumbnail_cache
|
||||
.0
|
||||
.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None)
|
||||
.await?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -179,7 +184,7 @@ pub async fn image_get_url(
|
||||
repository_handle: String,
|
||||
request: GetImageRequest,
|
||||
) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use std::fs;
|
||||
|
||||
let tag = request.tag.as_deref().unwrap_or("default");
|
||||
@@ -191,14 +196,18 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
|
||||
db_service.clone(),
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
).await {
|
||||
let image_data = fs::read(&cached_path)
|
||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
if let Some(cached_path) = thumbnail_cache
|
||||
.0
|
||||
.get_cached_path(
|
||||
db_service.clone(),
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let image_data =
|
||||
fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
||||
@@ -206,10 +215,14 @@ pub async fn image_get_url(
|
||||
|
||||
// Not cached — fetch from server and cache.
|
||||
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
||||
let _permit = image_semaphore().acquire().await
|
||||
let _permit = image_semaphore()
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| "Image download semaphore closed".to_string())?;
|
||||
|
||||
let repository = repository_manager.0.get(&repository_handle)
|
||||
let repository = repository_manager
|
||||
.0
|
||||
.get(&repository_handle)
|
||||
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||
|
||||
let image_type_enum = match request.image_type.as_str() {
|
||||
@@ -229,18 +242,23 @@ pub async fn image_get_url(
|
||||
};
|
||||
|
||||
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||
let image_data = repository.download_bytes(&server_url).await
|
||||
let image_data = repository
|
||||
.download_bytes(&server_url)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||
|
||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
&image_data,
|
||||
request.max_width.map(|w| w as i32),
|
||||
request.max_height.map(|h| h as i32),
|
||||
).await?;
|
||||
let cached_path = thumbnail_cache
|
||||
.0
|
||||
.save_thumbnail(
|
||||
db_service,
|
||||
&request.item_id,
|
||||
&request.image_type,
|
||||
tag,
|
||||
&image_data,
|
||||
request.max_width.map(|w| w as i32),
|
||||
request.max_height.map(|h| h as i32),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let base64_data = BASE64.encode(&image_data);
|
||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+101
-47
@@ -105,13 +105,21 @@ impl CredentialStore {
|
||||
}
|
||||
|
||||
/// Save an access token for a user
|
||||
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
|
||||
pub fn save_token(
|
||||
&self,
|
||||
user_id: &str,
|
||||
token: &str,
|
||||
) -> Result<CredentialResult, CredentialError> {
|
||||
if self.using_keyring {
|
||||
log::debug!("Saving token for user {} to keyring", user_id);
|
||||
self.save_to_keyring(user_id, token)?;
|
||||
Ok(CredentialResult::Keyring)
|
||||
} else {
|
||||
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
|
||||
log::debug!(
|
||||
"Saving token for user {} to encrypted file at {:?}",
|
||||
user_id,
|
||||
self.credentials_path
|
||||
);
|
||||
self.save_to_file(user_id, token)?;
|
||||
log::debug!("Successfully saved token to encrypted file");
|
||||
Ok(CredentialResult::EncryptedFile)
|
||||
@@ -124,7 +132,11 @@ impl CredentialStore {
|
||||
log::debug!("Getting token for user {} from keyring", user_id);
|
||||
self.get_from_keyring(user_id)
|
||||
} else {
|
||||
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
|
||||
log::debug!(
|
||||
"Getting token for user {} from encrypted file at {:?}",
|
||||
user_id,
|
||||
self.credentials_path
|
||||
);
|
||||
let result = self.get_from_file(user_id);
|
||||
if result.is_ok() {
|
||||
log::debug!("Successfully retrieved token from encrypted file");
|
||||
@@ -197,7 +209,7 @@ impl CredentialStore {
|
||||
.arg("__nonexistent_test__")
|
||||
.output()
|
||||
{
|
||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||
Ok(_) => true, // If command runs (even with no results), secret-tool is available
|
||||
Err(_) => false, // Command not found or can't execute
|
||||
}
|
||||
}
|
||||
@@ -232,8 +244,8 @@ impl CredentialStore {
|
||||
{
|
||||
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||
// See Technical Debt section in README.md for details
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let mut child = Command::new("secret-tool")
|
||||
@@ -248,20 +260,27 @@ impl CredentialStore {
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(token.as_bytes())
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
|
||||
stdin.write_all(token.as_bytes()).map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
|
||||
})?;
|
||||
}
|
||||
|
||||
let status = child.wait()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
|
||||
let status = child.wait().map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
|
||||
Err(CredentialError::Keyring(format!(
|
||||
"secret-tool failed with status: {}",
|
||||
status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +309,11 @@ impl CredentialStore {
|
||||
use std::process::Command;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
|
||||
log::debug!(
|
||||
"Looking up token with service={}, username={}",
|
||||
SERVICE_NAME,
|
||||
key
|
||||
);
|
||||
|
||||
let output = Command::new("secret-tool")
|
||||
.arg("lookup")
|
||||
@@ -299,18 +322,29 @@ impl CredentialStore {
|
||||
.arg("username")
|
||||
.arg(&key)
|
||||
.output()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
if output.status.success() {
|
||||
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
|
||||
log::debug!(
|
||||
"secret-tool lookup succeeded, token length: {}",
|
||||
output.stdout.len()
|
||||
);
|
||||
let token = String::from_utf8(output.stdout)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e))
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
Ok(token)
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
|
||||
log::warn!(
|
||||
"secret-tool lookup failed with status: {} stderr: {}",
|
||||
output.status,
|
||||
stderr
|
||||
);
|
||||
Err(CredentialError::NotFound)
|
||||
}
|
||||
}
|
||||
@@ -348,13 +382,18 @@ impl CredentialStore {
|
||||
.arg("username")
|
||||
.arg(&key)
|
||||
.status()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to run secret-tool: {}", e))
|
||||
})?;
|
||||
|
||||
// secret-tool clear returns success even if entry doesn't exist
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
|
||||
Err(CredentialError::Keyring(format!(
|
||||
"secret-tool clear failed with status: {}",
|
||||
status
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +437,7 @@ impl CredentialStore {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Try to read Android build properties from /system/build.prop
|
||||
let build_prop_paths = [
|
||||
"/system/build.prop",
|
||||
"/vendor/build.prop",
|
||||
];
|
||||
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
|
||||
|
||||
for path in &build_prop_paths {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
@@ -410,7 +446,8 @@ impl CredentialStore {
|
||||
if line.starts_with("ro.build.fingerprint=")
|
||||
|| line.starts_with("ro.serialno=")
|
||||
|| line.starts_with("ro.build.id=")
|
||||
|| line.starts_with("ro.product.model=") {
|
||||
|| line.starts_with("ro.product.model=")
|
||||
{
|
||||
hasher.update(line.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -439,8 +476,8 @@ impl CredentialStore {
|
||||
return Ok(serde_json::json!({}));
|
||||
}
|
||||
|
||||
let encrypted_data =
|
||||
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
let encrypted_data = fs::read_to_string(&self.credentials_path)
|
||||
.map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
|
||||
if encrypted_data.is_empty() {
|
||||
return Ok(serde_json::json!({}));
|
||||
@@ -456,19 +493,21 @@ impl CredentialStore {
|
||||
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let json =
|
||||
serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let encrypted = self.encrypt(&json)?;
|
||||
|
||||
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
// Generate a random nonce
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
getrandom::getrandom(&mut nonce_bytes)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
@@ -488,14 +527,16 @@ impl CredentialStore {
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
if combined.len() < 12 {
|
||||
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
|
||||
return Err(CredentialError::Encryption(
|
||||
"Invalid encrypted data".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (nonce_bytes, ciphertext) = combined.split_at(12);
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
|
||||
let cipher =
|
||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
@@ -686,32 +727,39 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
.new_string(&key)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||
let token_jstring = env
|
||||
.new_string(token)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
|
||||
let token_jstring = env.new_string(token).map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to create token string: {}", e))
|
||||
})?;
|
||||
|
||||
let result = env
|
||||
.call_method(
|
||||
instance,
|
||||
"saveToken",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z",
|
||||
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
|
||||
&[
|
||||
JValue::Object(&key_jstring.into()),
|
||||
JValue::Object(&token_jstring.into()),
|
||||
],
|
||||
)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
|
||||
.z()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
|
||||
})?;
|
||||
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring("saveToken returned false".to_string()))
|
||||
Err(CredentialError::Keyring(
|
||||
"saveToken returned false".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,8 +773,8 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
@@ -767,8 +815,8 @@ mod android_keystore {
|
||||
.attach_current_thread()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
let instance = get_secure_storage_instance(&mut env)
|
||||
.map_err(|e| CredentialError::Keyring(e))?;
|
||||
let instance =
|
||||
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||
|
||||
let key = format!("access_token:{}", user_id);
|
||||
let key_jstring = env
|
||||
@@ -784,19 +832,25 @@ mod android_keystore {
|
||||
)
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
|
||||
.z()
|
||||
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
CredentialError::Keyring(format!("Failed to get boolean result: {}", e))
|
||||
})?;
|
||||
|
||||
if result {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
|
||||
Err(CredentialError::Keyring(
|
||||
"deleteToken returned false".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export Android keystore functions at the module level for easier access
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
|
||||
pub use android_keystore::{
|
||||
initialize_secure_storage, test_keystore_available as android_test_keystore_available,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Jellyfin → domain translation.
|
||||
//!
|
||||
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
|
||||
//! second provider later means a sibling `from_<provider>.rs`; the domain types
|
||||
//! and every consumer stay untouched.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use super::media::{MediaKind, StreamKind};
|
||||
|
||||
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
|
||||
/// Total and panic-free.
|
||||
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
|
||||
match stream_type {
|
||||
"Audio" => StreamKind::Audio,
|
||||
"Video" => StreamKind::Video,
|
||||
"Subtitle" => StreamKind::Subtitle,
|
||||
_ => StreamKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
|
||||
/// The frontend must never see ticks — this is where they die.
|
||||
const TICKS_PER_MILLISECOND: i64 = 10_000;
|
||||
|
||||
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
|
||||
///
|
||||
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
|
||||
pub fn ticks_to_ms(ticks: i64) -> i64 {
|
||||
ticks / TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
|
||||
///
|
||||
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
|
||||
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
|
||||
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
|
||||
///
|
||||
/// The recognised set is every `item_type` the frontend audit found in use
|
||||
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
|
||||
/// subtypes Jellyfin returns in `People[].Type`.
|
||||
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
|
||||
match item_type {
|
||||
// Music
|
||||
"Audio" | "MusicVideo" => MediaKind::Track,
|
||||
"MusicAlbum" => MediaKind::Album,
|
||||
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
|
||||
"Playlist" => MediaKind::Playlist,
|
||||
|
||||
// Video
|
||||
"Movie" => MediaKind::Movie,
|
||||
"Series" => MediaKind::Series,
|
||||
"Season" => MediaKind::Season,
|
||||
"Episode" => MediaKind::Episode,
|
||||
// A bare video leaf with no richer classification.
|
||||
"Video" => MediaKind::Movie,
|
||||
|
||||
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
|
||||
// people (Actor/Director/Writer/Composer/…) in People[].Type.
|
||||
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
|
||||
MediaKind::Person
|
||||
}
|
||||
|
||||
// A live TV channel: playable, but a non-seekable live stream.
|
||||
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
|
||||
// A bare channel is a container the user drills into.
|
||||
"Channel" => MediaKind::Channel,
|
||||
|
||||
// Containers
|
||||
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
|
||||
// ChannelFolderItem is a container when it is a folder, else a playable
|
||||
// channel leaf (distinct kind so the UI can route it to playback).
|
||||
"ChannelFolderItem" => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::ChannelItem
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown → safe sink. Never panics.
|
||||
_ => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticks_convert_to_milliseconds() {
|
||||
// 1 second = 10,000,000 ticks = 1000 ms
|
||||
assert_eq!(ticks_to_ms(10_000_000), 1000);
|
||||
// 90.5 s
|
||||
assert_eq!(ticks_to_ms(905_000_000), 90_500);
|
||||
assert_eq!(ticks_to_ms(0), 0);
|
||||
// Sub-millisecond truncates toward zero, not panics.
|
||||
assert_eq!(ticks_to_ms(9_999), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
|
||||
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
|
||||
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
|
||||
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
|
||||
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
|
||||
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
|
||||
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
|
||||
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_and_role_types_map_to_person() {
|
||||
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
|
||||
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_and_container_types_map() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("TvChannel", false),
|
||||
MediaKind::LiveChannel
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("CollectionFolder", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_folder_item_disambiguates_on_is_folder() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", false),
|
||||
MediaKind::ChannelItem
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_kinds_map() {
|
||||
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
|
||||
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
|
||||
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
|
||||
assert_eq!(
|
||||
stream_kind_from_jellyfin("EmbeddedImage"),
|
||||
StreamKind::Other
|
||||
);
|
||||
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_never_panics_and_falls_back() {
|
||||
// The whole point: garbage in, safe kind out, no panic.
|
||||
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
|
||||
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("SomeFutureType", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Canonical, provider-neutral media domain model.
|
||||
//!
|
||||
//! This is the *single source of truth* for what a media item is across the
|
||||
//! whole app. Rust (repositories, player, downloads) uses these types directly;
|
||||
//! the frontend consumes the tauri-specta-generated projection in
|
||||
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
|
||||
//! language, so the model cannot drift.
|
||||
//!
|
||||
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
|
||||
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
|
||||
//! place provider terms touch the domain type.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The kind of a media item — provider-neutral classification.
|
||||
///
|
||||
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
|
||||
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
|
||||
/// typo or an unhandled kind is a compile error on the frontend, not a silent
|
||||
/// runtime miss across ~127 comparison sites.
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
// Music
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
Playlist,
|
||||
// Video
|
||||
Movie,
|
||||
Series,
|
||||
Season,
|
||||
Episode,
|
||||
// Cast/crew
|
||||
Person,
|
||||
// Containers / live TV
|
||||
/// A channel *container* the user drills into (Jellyfin `Channel`).
|
||||
Channel,
|
||||
Folder,
|
||||
/// A live TV channel — playable, but a live stream with no seekable
|
||||
/// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
|
||||
LiveChannel,
|
||||
/// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
|
||||
/// not itself a folder) — e.g. a plugin-channel VOD item that has no
|
||||
/// dedicated item type but carries its own media streams. Playable and
|
||||
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
|
||||
/// and from `Other` so the UI can route it to playback.
|
||||
ChannelItem,
|
||||
/// A kind we do not model explicitly. Reached only for provider item types
|
||||
/// that map to nothing meaningful; consumers treat it like an opaque
|
||||
/// container. The mapping must be *total* — it never panics — so this is the
|
||||
/// safe sink for unknown strings. Also the `Default`, so a defaulted
|
||||
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The kind of a media stream within an item (audio track, video track,
|
||||
/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StreamKind {
|
||||
Audio,
|
||||
Video,
|
||||
Subtitle,
|
||||
/// Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MediaKind {
|
||||
/// True for kinds that are containers/collections rather than playable leaves.
|
||||
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
|
||||
// Consumed by later migration phases (drill-vs-play routing); kept now so the
|
||||
// domain surface is complete alongside the type it describes.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_container(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
MediaKind::Album
|
||||
| MediaKind::Artist
|
||||
| MediaKind::Series
|
||||
| MediaKind::Season
|
||||
| MediaKind::Playlist
|
||||
| MediaKind::Channel
|
||||
| MediaKind::Folder
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Canonical, provider-neutral domain model — the single source of truth for
|
||||
//! the app's core data shapes, shared with the frontend via generated bindings.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
pub mod from_jellyfin;
|
||||
pub mod media;
|
||||
|
||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||
pub use media::{MediaKind, StreamKind};
|
||||
@@ -36,7 +36,7 @@ impl Default for CacheConfig {
|
||||
album_affinity_enabled: true,
|
||||
album_affinity_threshold: 3,
|
||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
wifi_only: false, // Allow preloading on any connection by default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,20 @@ impl SmartCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if should pre-cache queue items
|
||||
/// Check if should pre-cache queue items.
|
||||
///
|
||||
/// Note this deliberately does NOT consult `wifi_only`. It used to return
|
||||
/// `queue_precache_enabled && !wifi_only`, which disabled precaching
|
||||
/// outright whenever the user enabled WiFi-only — regardless of the network
|
||||
/// actually in use. The network check now lives in the download queue pump
|
||||
/// (`downloads_allowed_on_current_network`), which is the single gate for
|
||||
/// all download traffic, so this only answers "is precaching enabled?".
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn should_precache_queue(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
||||
.map(|cfg| cfg.queue_precache_enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -225,7 +234,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;
|
||||
}
|
||||
@@ -279,6 +291,19 @@ mod tests {
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wifi_only_does_not_disable_precaching() {
|
||||
// wifi_only must not short-circuit precaching: the network gate lives in
|
||||
// the download pump, which checks the *actual* transport. Enabling
|
||||
// WiFi-only while on WiFi should still precache.
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = true;
|
||||
config.wifi_only = true;
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
|
||||
@@ -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,15 @@ 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 },
|
||||
/// The queue is holding: WiFi-only is enabled and the current network is
|
||||
/// metered/cellular. Pending rows stay pending and resume on network change.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
WaitingForNetwork,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -98,9 +91,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();
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Network transport classification for the WiFi-only download gate.
|
||||
//!
|
||||
//! This answers "what kind of connection are we on?", which is orthogonal to
|
||||
//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
|
||||
//! pump consults this before starting pending rows when the user has enabled
|
||||
//! WiFi-only downloads.
|
||||
//!
|
||||
//! On Android the real transport is read from `NetworkCapabilities` in
|
||||
//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
|
||||
//! no metered-connection concept worth enforcing, so we report `Ethernet`,
|
||||
//! which is always acceptable — gating desktop downloads would be a regression.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Kind of network transport currently active.
|
||||
///
|
||||
/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
|
||||
/// in sync (the serde rename below is what the frontend sends).
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkType {
|
||||
/// No active network.
|
||||
None,
|
||||
/// WiFi (may still be metered — check `unmetered`).
|
||||
Wifi,
|
||||
/// Wired ethernet, typical on Android TV and desktop.
|
||||
Ethernet,
|
||||
/// Mobile data — never acceptable when wifi-only is enabled.
|
||||
Cellular,
|
||||
/// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
|
||||
Other,
|
||||
/// Could not determine the transport.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Current network transport plus whether it is metered.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkState {
|
||||
pub network_type: NetworkType,
|
||||
/// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
impl Default for NetworkState {
|
||||
fn default() -> Self {
|
||||
// Desktop default: wired and unmetered, so the gate never blocks there.
|
||||
// Android overwrites this as soon as the frontend reports the real state.
|
||||
Self {
|
||||
network_type: NetworkType::Ethernet,
|
||||
unmetered: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkState {
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
///
|
||||
/// Ethernet counts as acceptable — it is unmetered in practice and is what
|
||||
/// Android TV devices use. Cellular never does. `None`/`Unknown` fail
|
||||
/// closed: if we cannot tell what we are on, we do not spend the user's
|
||||
/// mobile data to find out.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
match self.network_type {
|
||||
NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
|
||||
// Require unmetered so metered WiFi hotspots (backed by the very
|
||||
// cellular data this setting protects) are excluded too.
|
||||
NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, mutable view of the current network transport.
|
||||
///
|
||||
/// Cheap to clone; the frontend updates it via `set_network_state` whenever
|
||||
/// Android reports a network change.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NetworkStateHandle {
|
||||
state: Arc<RwLock<NetworkState>>,
|
||||
}
|
||||
|
||||
impl NetworkStateHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(NetworkState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> NetworkState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
pub async fn set(&self, new_state: NetworkState) {
|
||||
*self.state.write().await = new_state;
|
||||
}
|
||||
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
pub async fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
self.state.read().await.allows_download(wifi_only)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
|
||||
NetworkState {
|
||||
network_type,
|
||||
unmetered,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_only_off_allows_every_transport() {
|
||||
for t in [
|
||||
NetworkType::None,
|
||||
NetworkType::Wifi,
|
||||
NetworkType::Ethernet,
|
||||
NetworkType::Cellular,
|
||||
NetworkType::Other,
|
||||
NetworkType::Unknown,
|
||||
] {
|
||||
assert!(
|
||||
state(t, false).allows_download(false),
|
||||
"{t:?} should be allowed when wifi_only is off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_is_blocked_when_wifi_only() {
|
||||
// Even if somehow flagged unmetered, cellular is never acceptable.
|
||||
assert!(!state(NetworkType::Cellular, true).allows_download(true));
|
||||
assert!(!state(NetworkType::Cellular, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmetered_wifi_and_ethernet_are_allowed() {
|
||||
assert!(state(NetworkType::Wifi, true).allows_download(true));
|
||||
assert!(state(NetworkType::Ethernet, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metered_wifi_is_blocked() {
|
||||
// A phone hotspot reports as WiFi but is metered — blocking it is the
|
||||
// whole point of checking NOT_METERED rather than the transport alone.
|
||||
assert!(!state(NetworkType::Wifi, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_none_fail_closed() {
|
||||
assert!(!state(NetworkType::Unknown, true).allows_download(true));
|
||||
assert!(!state(NetworkType::None, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_default_is_never_gated() {
|
||||
assert!(NetworkState::default().allows_download(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_roundtrips_state() {
|
||||
let handle = NetworkStateHandle::new();
|
||||
assert!(handle.allows_download(true).await);
|
||||
|
||||
handle.set(state(NetworkType::Cellular, false)).await;
|
||||
assert!(!handle.allows_download(true).await);
|
||||
assert!(handle.allows_download(false).await);
|
||||
assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_type_serializes_lowercase() {
|
||||
// Must match the string constants in NetworkTypeMonitor.kt.
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Wifi).unwrap(),
|
||||
"\"wifi\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Cellular).unwrap(),
|
||||
"\"cellular\""
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -193,7 +224,7 @@ impl JellyfinClient {
|
||||
}
|
||||
|
||||
/// Report playback progress to Jellyfin
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub async fn report_playback_progress(
|
||||
&self,
|
||||
item_id: String,
|
||||
@@ -220,9 +251,17 @@ impl JellyfinClient {
|
||||
start_position_ticks: Option<i64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Playing on session: {}", session_id);
|
||||
log::info!("[JellyfinClient] Item IDs: {:?}, Start index: {}", item_ids, start_index);
|
||||
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id, item_ids.len(), start_index);
|
||||
log::info!(
|
||||
"[JellyfinClient] Item IDs: {:?}, Start index: {}",
|
||||
item_ids,
|
||||
start_index
|
||||
);
|
||||
debug!(
|
||||
"[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||
session_id,
|
||||
item_ids.len(),
|
||||
start_index
|
||||
);
|
||||
|
||||
// Build URL with query parameters (Jellyfin expects PascalCase query params)
|
||||
let mut url = format!(
|
||||
@@ -244,10 +283,15 @@ impl JellyfinClient {
|
||||
log::info!("[JellyfinClient] POST {}", url);
|
||||
debug!("[JellyfinClient] Full URL length: {} chars", url.len());
|
||||
// Don't log full URL as it may contain sensitive tokens, just log the endpoint
|
||||
debug!("[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds", session_id, item_ids.len());
|
||||
debug!(
|
||||
"[JellyfinClient] POST to Sessions/{}/Playing with {} itemIds",
|
||||
session_id,
|
||||
item_ids.len()
|
||||
);
|
||||
|
||||
debug!("[JellyfinClient] Sending HTTP POST request...");
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -263,10 +307,21 @@ impl JellyfinClient {
|
||||
debug!("[JellyfinClient] Response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("[JellyfinClient] Request failed: {}", error_text);
|
||||
error!("[JellyfinClient] API error {}: {}", status.as_u16(), error_text);
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
error!(
|
||||
"[JellyfinClient] API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
);
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Successfully sent play command to remote session");
|
||||
@@ -280,7 +335,11 @@ impl JellyfinClient {
|
||||
session_id: String,
|
||||
command: &str,
|
||||
) -> Result<(), String> {
|
||||
self.post(&format!("/Sessions/{}/Playing/{}", session_id, command), &serde_json::json!({})).await
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Playing/{}", session_id, command),
|
||||
&serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Seek on a remote session
|
||||
@@ -298,7 +357,8 @@ impl JellyfinClient {
|
||||
self.config.server_url, session_id, position_ticks
|
||||
);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -307,11 +367,22 @@ impl JellyfinClient {
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
|
||||
log::info!(
|
||||
"[JellyfinClient] Seek to {} ticks on session {}",
|
||||
position_ticks,
|
||||
session_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -332,46 +403,42 @@ impl JellyfinClient {
|
||||
payload["Arguments"] = args;
|
||||
}
|
||||
|
||||
log::info!("[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
||||
command_name, session_id, serde_json::to_string(&payload).unwrap_or_default());
|
||||
log::info!(
|
||||
"[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
||||
command_name,
|
||||
session_id,
|
||||
serde_json::to_string(&payload).unwrap_or_default()
|
||||
);
|
||||
|
||||
self.post(
|
||||
&format!("/Sessions/{}/Command", session_id),
|
||||
&payload
|
||||
).await
|
||||
self.post(&format!("/Sessions/{}/Command", session_id), &payload)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Set volume on a remote session
|
||||
pub async fn session_set_volume(
|
||||
&self,
|
||||
session_id: String,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
pub async fn session_set_volume(&self, session_id: String, volume: i32) -> Result<(), String> {
|
||||
self.send_general_command(
|
||||
&session_id,
|
||||
"SetVolume",
|
||||
Some(serde_json::json!({ "Volume": volume.to_string() })),
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Toggle mute on a remote session
|
||||
pub async fn session_toggle_mute(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn session_toggle_mute(&self, session_id: String) -> Result<(), String> {
|
||||
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
||||
|
||||
self.send_general_command(
|
||||
&session_id,
|
||||
"ToggleMute",
|
||||
None,
|
||||
).await
|
||||
self.send_general_command(&session_id, "ToggleMute", None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all active sessions
|
||||
pub async fn get_sessions(&self) -> Result<Vec<SessionInfo>, String> {
|
||||
let sessions: Vec<SessionInfo> = self.get("/Sessions").await?;
|
||||
info!("[JellyfinClient] Fetched {} sessions from API", sessions.len());
|
||||
info!(
|
||||
"[JellyfinClient] Fetched {} sessions from API",
|
||||
sessions.len()
|
||||
);
|
||||
for session in &sessions {
|
||||
debug!("[JellyfinClient] Session: id={:?}, device={:?}, client={:?}, supportsRemoteControl={}",
|
||||
session.id, session.device_name, session.client, session.supports_remote_control);
|
||||
@@ -382,7 +449,9 @@ impl JellyfinClient {
|
||||
/// Get a specific session by ID
|
||||
pub async fn get_session(&self, session_id: &str) -> Result<Option<SessionInfo>, String> {
|
||||
let sessions = self.get_sessions().await?;
|
||||
Ok(sessions.into_iter().find(|s| s.id.as_deref() == Some(session_id)))
|
||||
Ok(sessions
|
||||
.into_iter()
|
||||
.find(|s| s.id.as_deref() == Some(session_id)))
|
||||
}
|
||||
|
||||
// --- JellyLMS multi-room sync groups -----------------------------------
|
||||
@@ -415,12 +484,14 @@ impl JellyfinClient {
|
||||
|
||||
/// Remove a single LMS player from whatever sync group it's in.
|
||||
pub async fn lms_unsync_player(&self, mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac)).await
|
||||
self.delete(&format!("/JellyLms/SyncGroups/Players/{}", mac))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||
pub async fn lms_dissolve_sync_group(&self, master_mac: &str) -> Result<(), String> {
|
||||
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac)).await
|
||||
self.delete(&format!("/JellyLms/SyncGroups/{}", master_mac))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Make a DELETE request to the Jellyfin API (used by the JellyLMS endpoints).
|
||||
@@ -429,7 +500,8 @@ impl JellyfinClient {
|
||||
|
||||
log::debug!("[JellyfinClient] DELETE {}", endpoint);
|
||||
|
||||
let response = self.http_client
|
||||
let response = self
|
||||
.http_client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.get_auth_header())
|
||||
.send()
|
||||
@@ -438,8 +510,15 @@ impl JellyfinClient {
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!(
|
||||
"Jellyfin API error {}: {}",
|
||||
status.as_u16(),
|
||||
error_text
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -570,7 +649,6 @@ pub struct PlayState {
|
||||
pub shuffle_mode: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub enum ErrorKind {
|
||||
/// Enhanced HTTP client with retry logic and error classification
|
||||
#[derive(Clone)]
|
||||
pub struct HttpClient {
|
||||
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||
pub(crate) client: Client, // Make accessible within crate for custom requests
|
||||
config: HttpConfig,
|
||||
}
|
||||
|
||||
@@ -131,18 +131,15 @@ impl HttpClient {
|
||||
/// Check if a request should be retried based on the error
|
||||
pub fn should_retry(error: &reqwest::Error) -> bool {
|
||||
match Self::classify_error(error) {
|
||||
ErrorKind::Network => true, // Retry network errors
|
||||
ErrorKind::Server => true, // Retry 5xx server errors
|
||||
ErrorKind::Network => true, // Retry network errors
|
||||
ErrorKind::Server => true, // Retry 5xx server errors
|
||||
ErrorKind::Authentication => false, // Don't retry 401/403
|
||||
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||
ErrorKind::Client => false, // Don't retry other 4xx errors
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a request with automatic retry on network errors
|
||||
pub async fn request_with_retry(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<Response, reqwest::Error> {
|
||||
pub async fn request_with_retry(&self, request: Request) -> Result<Response, reqwest::Error> {
|
||||
let max_retries = self.config.max_retries;
|
||||
let mut last_error: Option<reqwest::Error> = None;
|
||||
|
||||
@@ -192,44 +189,58 @@ impl HttpClient {
|
||||
Err(last_error.unwrap())
|
||||
}
|
||||
|
||||
/// Make a GET request with retry
|
||||
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
|
||||
let request = self.client.get(url).build()?;
|
||||
self.request_with_retry(request).await
|
||||
}
|
||||
/// Make a GET request and deserialize JSON with a short timeout and no retries.
|
||||
///
|
||||
/// Intended for the initial "connect to server" probe on the login screen:
|
||||
/// a wrong/unreachable URL must fail fast instead of burning through the
|
||||
/// default 30s-per-attempt timeout and exponential backoff retries.
|
||||
pub async fn get_json_fast<T: DeserializeOwned>(&self, url: &str) -> Result<T, String> {
|
||||
// Short timeout so an unreachable host fails quickly.
|
||||
const FAST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Make a GET request and deserialize JSON response with retry
|
||||
pub async fn get_json_with_retry<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<T, String> {
|
||||
let response = self.get_with_retry(url).await
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(FAST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// No retry: connection failures on a wrong URL won't succeed on retry,
|
||||
// they'd only multiply the wait the user sees before an error.
|
||||
let response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
response.json::<T>().await
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||
}
|
||||
|
||||
/// Quick ping to check if a server is reachable (no retry)
|
||||
pub async fn ping(&self, url: &str) -> bool {
|
||||
let request = self.client.get(url)
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
|
||||
.build();
|
||||
|
||||
match request {
|
||||
Ok(req) => {
|
||||
match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
Ok(req) => match self.client.execute(req).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ pub struct PlaybackStoppedRequest {
|
||||
/// Request body for reporting playback progress
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
#[allow(dead_code)] // Will be used when playback_reporting is integrated
|
||||
pub struct PlaybackProgressRequest {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
|
||||
+355
-118
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod playback_mode;
|
||||
@@ -14,119 +15,291 @@ 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::{
|
||||
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,
|
||||
delete_downloads_under,
|
||||
// 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_downloads_allowed,
|
||||
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_download_disk_usage,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_downloaded_libraries,
|
||||
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_network_state,
|
||||
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
|
||||
@@ -137,7 +310,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;
|
||||
@@ -146,7 +319,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).
|
||||
///
|
||||
@@ -209,7 +384,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;
|
||||
@@ -296,7 +475,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),
|
||||
}
|
||||
@@ -354,9 +536,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);
|
||||
@@ -369,13 +558,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());
|
||||
}
|
||||
}
|
||||
@@ -439,6 +636,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,
|
||||
@@ -578,6 +777,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
delete_download,
|
||||
delete_all_downloads,
|
||||
delete_album_downloads,
|
||||
delete_downloads_under,
|
||||
clear_stale_downloads,
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
@@ -585,11 +785,18 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
start_download,
|
||||
enqueue_download,
|
||||
enqueue_video_downloads,
|
||||
sync_full_catalog,
|
||||
catalog_sync_status,
|
||||
set_show_server_catalog,
|
||||
resume_queued_downloads,
|
||||
get_download_manager_stats,
|
||||
set_max_concurrent_downloads,
|
||||
get_smart_cache_stats,
|
||||
update_smart_cache_config,
|
||||
get_smart_cache_config,
|
||||
// WiFi-only download gate (UR-053)
|
||||
set_network_state,
|
||||
get_downloads_allowed,
|
||||
get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
// Pinning commands
|
||||
@@ -639,6 +846,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_libraries,
|
||||
repository_get_items,
|
||||
repository_get_item,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_download_disk_usage,
|
||||
repository_jray_actors_at,
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
@@ -651,6 +861,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,
|
||||
@@ -717,7 +928,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
|
||||
@@ -920,6 +1136,9 @@ pub fn run() {
|
||||
player_arc.clone(),
|
||||
);
|
||||
let playback_mode_arc = Arc::new(playback_mode_manager);
|
||||
// Broadcast mode changes so the frontend's mirror store reconciles to
|
||||
// this authoritative one (prevents remote/local control desync).
|
||||
playback_mode_arc.set_event_emitter(event_emitter.clone());
|
||||
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
|
||||
app.manage(playback_mode_wrapper);
|
||||
|
||||
@@ -930,9 +1149,11 @@ pub fn run() {
|
||||
playback_mode_arc.clone(),
|
||||
);
|
||||
session_poller.set_event_emitter(event_emitter.clone());
|
||||
session_poller.start();
|
||||
// Note: start() is deferred until after the connectivity monitor is
|
||||
// created below, so the poller can report reachability from its first
|
||||
// poll (it drives offline detection + recovery while the user is idle).
|
||||
let session_poller_arc = Arc::new(session_poller);
|
||||
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc);
|
||||
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
|
||||
app.manage(session_poller_wrapper);
|
||||
|
||||
// On Android, set up the MediaSession (lockscreen) handler and the
|
||||
@@ -959,6 +1180,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") {
|
||||
@@ -986,6 +1210,13 @@ pub fn run() {
|
||||
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
||||
app.manage(download_manager_wrapper);
|
||||
|
||||
// Current network transport, for the WiFi-only download gate (UR-053).
|
||||
// Defaults to unmetered ethernet so desktop is never gated; Android
|
||||
// overwrites it via set_network_state as soon as the UI starts.
|
||||
app.manage(commands::download::NetworkStateWrapper(
|
||||
download::network::NetworkStateHandle::new(),
|
||||
));
|
||||
|
||||
// Initialize connectivity monitor
|
||||
info!("[INIT] Initializing connectivity monitor...");
|
||||
let http_config = HttpConfig::default();
|
||||
@@ -994,6 +1225,12 @@ pub fn run() {
|
||||
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
|
||||
connectivity_monitor.set_app_handle(app.handle().clone());
|
||||
|
||||
// Wire the connectivity reporter into the session poller so its
|
||||
// continuous background polls drive reachability (offline detection
|
||||
// + recovery) even when the user isn't browsing, then start it.
|
||||
session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
|
||||
session_poller_arc.start();
|
||||
|
||||
// Wrap in Arc for sharing with AuthManager
|
||||
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
|
||||
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
|
||||
@@ -1039,7 +1276,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`.
|
||||
@@ -1047,9 +1283,10 @@ mod specta_bindings {
|
||||
fn export_typescript_bindings() {
|
||||
super::specta_builder()
|
||||
.export(
|
||||
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||
specta_typescript::Typescript::default()
|
||||
.bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||
"../src/lib/api/bindings.ts",
|
||||
)
|
||||
.expect("failed to export typescript bindings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::jellyfin::JellyfinClient;
|
||||
use crate::player::{PlayerController, QueueContext};
|
||||
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
|
||||
|
||||
/// Playback mode - local device, remote session, or idle
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -48,6 +48,9 @@ pub struct PlaybackModeManager {
|
||||
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||
current_mode: Arc<RwLock<PlaybackMode>>,
|
||||
is_transferring: Arc<AtomicBool>,
|
||||
/// Optional emitter used to notify the frontend when the mode changes, so its
|
||||
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||
}
|
||||
|
||||
impl PlaybackModeManager {
|
||||
@@ -61,19 +64,76 @@ impl PlaybackModeManager {
|
||||
player_controller,
|
||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||
event_emitter: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
|
||||
/// during setup; safe to leave unset (tests do), in which case mode changes
|
||||
/// simply aren't broadcast.
|
||||
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
||||
*self.event_emitter.lock_safe() = Some(emitter);
|
||||
}
|
||||
|
||||
/// Get current playback mode
|
||||
pub fn get_mode(&self) -> PlaybackMode {
|
||||
self.current_mode.read_safe().clone()
|
||||
}
|
||||
|
||||
/// Set playback mode (internal use)
|
||||
/// Set playback mode (internal use).
|
||||
///
|
||||
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
|
||||
/// the frontend's mirror store reconciles to this authoritative value. The
|
||||
/// write lock is released before emitting to avoid holding it across the
|
||||
/// emitter call.
|
||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||
let mut current = self.current_mode.write_safe();
|
||||
*current = mode;
|
||||
let changed = {
|
||||
let mut current = self.current_mode.write_safe();
|
||||
let changed = *current != mode;
|
||||
*current = mode.clone();
|
||||
changed
|
||||
};
|
||||
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
|
||||
let (mode_str, session_id) = match &mode {
|
||||
PlaybackMode::Local => ("local".to_string(), None),
|
||||
PlaybackMode::Idle => ("idle".to_string(), None),
|
||||
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
|
||||
};
|
||||
|
||||
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
|
||||
mode: mode_str,
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the Android playback service and hand it remote-volume control.
|
||||
///
|
||||
/// Must run on EVERY transition into remote mode, because it is what starts
|
||||
/// the foreground service. Without a running service there is no media
|
||||
/// notification (the lockscreen card is missing) AND system volume buttons
|
||||
/// aren't intercepted for the remote session (remote volume control dead).
|
||||
/// Both symptoms share this one cause, so this must not be skipped on any
|
||||
/// remote-entry path (notably the empty-queue early return in
|
||||
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
||||
#[allow(unused_variables)]
|
||||
fn enable_remote_control(&self) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if currently transferring
|
||||
@@ -97,8 +157,16 @@ impl PlaybackModeManager {
|
||||
/// Send volume command to remote session
|
||||
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||
#[allow(dead_code)] // Called from Android JNI callback
|
||||
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
|
||||
pub async fn send_remote_volume_command(
|
||||
&self,
|
||||
command: &str,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
|
||||
command,
|
||||
volume
|
||||
);
|
||||
|
||||
// Get the current session ID
|
||||
let session_id = match self.get_mode() {
|
||||
@@ -109,18 +177,18 @@ impl PlaybackModeManager {
|
||||
}
|
||||
};
|
||||
|
||||
log::info!("[PlaybackMode] Current mode is Remote, session_id={}", session_id);
|
||||
log::info!(
|
||||
"[PlaybackMode] Current mode is Remote, session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// Get Jellyfin client
|
||||
let client = {
|
||||
log::info!("[PlaybackMode] Attempting to lock Jellyfin client...");
|
||||
let client_opt = self
|
||||
.jellyfin_client
|
||||
.lock()
|
||||
.map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
|
||||
log::info!("[PlaybackMode] Jellyfin client lock acquired");
|
||||
|
||||
@@ -139,7 +207,12 @@ impl PlaybackModeManager {
|
||||
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
||||
|
||||
// Send the volume command
|
||||
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
|
||||
log::info!(
|
||||
"[PlaybackMode] Sending {} command to session {} (volume: {})",
|
||||
command,
|
||||
session_id,
|
||||
volume
|
||||
);
|
||||
let result = client.session_set_volume(session_id, volume).await;
|
||||
|
||||
match &result {
|
||||
@@ -152,8 +225,11 @@ impl PlaybackModeManager {
|
||||
|
||||
/// Extract Jellyfin item IDs from queue items
|
||||
/// Returns (item_ids, adjusted_current_index)
|
||||
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
|
||||
|
||||
fn extract_jellyfin_ids(
|
||||
&self,
|
||||
items: &[crate::player::MediaItem],
|
||||
original_index: usize,
|
||||
) -> Result<(Vec<String>, usize), String> {
|
||||
let mut jellyfin_ids: Vec<String> = Vec::new();
|
||||
let mut adjusted_index: Option<usize> = None;
|
||||
let mut jellyfin_item_count = 0;
|
||||
@@ -179,7 +255,9 @@ impl PlaybackModeManager {
|
||||
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
||||
original_index
|
||||
);
|
||||
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
|
||||
return Err(
|
||||
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -212,7 +290,9 @@ impl PlaybackModeManager {
|
||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||
|
||||
// Perform the transfer
|
||||
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
|
||||
let result = self
|
||||
.transfer_to_remote_inner(&session_id, position_override)
|
||||
.await;
|
||||
|
||||
// Clear transferring flag
|
||||
self.is_transferring.store(false, Ordering::Relaxed);
|
||||
@@ -226,7 +306,10 @@ impl PlaybackModeManager {
|
||||
position_override: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
||||
debug!(
|
||||
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// If we're already controlling a remote session, that *old* session — not
|
||||
// the idle local player — is the source of truth for the current track and
|
||||
@@ -250,13 +333,26 @@ impl PlaybackModeManager {
|
||||
let original_index = queue.current_index().unwrap_or(0);
|
||||
let items = queue.items();
|
||||
|
||||
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
log::info!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
debug!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
|
||||
// Log each item's jellyfin_id for debugging
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
||||
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
|
||||
log::debug!(
|
||||
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
|
||||
i,
|
||||
item.id,
|
||||
jf_id
|
||||
);
|
||||
}
|
||||
|
||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||
@@ -297,6 +393,10 @@ impl PlaybackModeManager {
|
||||
self.set_mode(PlaybackMode::Remote {
|
||||
session_id: session_id.to_string(),
|
||||
});
|
||||
// Start the service + remote-volume control here too — otherwise this
|
||||
// early return leaves remote mode with no media notification and no
|
||||
// volume interception (lockscreen card missing + remote volume dead).
|
||||
self.enable_remote_control();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -311,14 +411,11 @@ impl PlaybackModeManager {
|
||||
log::info!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||
debug!("[PlaybackMode] Getting Jellyfin client for transfer...");
|
||||
let client = {
|
||||
let client_opt = self
|
||||
.jellyfin_client
|
||||
.lock()
|
||||
.map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
let client_opt = self.jellyfin_client.lock().map_err(|e| {
|
||||
log::error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
error!("[PlaybackMode] Failed to lock Jellyfin client: {}", e);
|
||||
format!("Failed to lock Jellyfin client: {}", e)
|
||||
})?;
|
||||
|
||||
match client_opt.as_ref() {
|
||||
Some(c) => {
|
||||
@@ -344,7 +441,9 @@ impl PlaybackModeManager {
|
||||
match client.get_session(prev_session_id).await {
|
||||
Ok(Some(session)) => {
|
||||
// Resume at the previous session's position.
|
||||
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
|
||||
if let Some(ticks) =
|
||||
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
|
||||
{
|
||||
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
||||
log::info!(
|
||||
"[PlaybackMode] Using previous remote position: {:.2}s",
|
||||
@@ -352,7 +451,11 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
// Resume on whichever track the previous session reached.
|
||||
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
|
||||
if let Some(now_id) = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.and_then(|i| i.id.as_deref())
|
||||
{
|
||||
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
||||
log::info!(
|
||||
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
||||
@@ -369,8 +472,13 @@ impl PlaybackModeManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
|
||||
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
|
||||
Ok(None) => log::warn!(
|
||||
"[PlaybackMode] Previous remote session not found while reading state"
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"[PlaybackMode] Failed to read previous remote session: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +487,10 @@ impl PlaybackModeManager {
|
||||
|
||||
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||
match &queue_context {
|
||||
QueueContext::Album { album_id, album_name } => {
|
||||
QueueContext::Album {
|
||||
album_id,
|
||||
album_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
||||
album_name,
|
||||
@@ -387,7 +498,10 @@ impl PlaybackModeManager {
|
||||
queue_ids.len()
|
||||
);
|
||||
}
|
||||
QueueContext::Playlist { playlist_id, playlist_name } => {
|
||||
QueueContext::Playlist {
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
||||
playlist_name,
|
||||
@@ -479,7 +593,11 @@ impl PlaybackModeManager {
|
||||
return Err("Remote session not found".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Error polling session (attempt {}): {}",
|
||||
attempts,
|
||||
e
|
||||
);
|
||||
// Continue polling - transient errors are OK
|
||||
}
|
||||
}
|
||||
@@ -511,9 +629,15 @@ impl PlaybackModeManager {
|
||||
// up with two devices playing at once. Do this only after the new session
|
||||
// is confirmed playing, so a failure here doesn't leave us with silence.
|
||||
if let Some(prev_session_id) = previous_remote_session {
|
||||
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping previous remote session {}",
|
||||
prev_session_id
|
||||
);
|
||||
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
||||
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop previous remote session: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +657,9 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
|
||||
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
player
|
||||
.stop()
|
||||
.map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
|
||||
// Log queue state AFTER stop (should be unchanged)
|
||||
{
|
||||
@@ -552,14 +678,9 @@ impl PlaybackModeManager {
|
||||
session_id: session_id.to_string(),
|
||||
});
|
||||
|
||||
// Enable remote volume control on Android (intercepts volume buttons)
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!("[PlaybackMode] Failed to enable remote volume: {}", e);
|
||||
// Non-fatal - continue with transfer
|
||||
}
|
||||
}
|
||||
// Start the service + remote-volume control (intercepts volume buttons,
|
||||
// and starts the foreground service that renders the lockscreen card).
|
||||
self.enable_remote_control();
|
||||
|
||||
log::info!("[PlaybackMode] Successfully transferred to remote");
|
||||
Ok(())
|
||||
@@ -621,11 +742,20 @@ impl PlaybackModeManager {
|
||||
};
|
||||
|
||||
// Stop remote playback
|
||||
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
|
||||
match client.send_session_command(session_id.clone(), "Stop").await {
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping remote playback on session: {}",
|
||||
session_id
|
||||
);
|
||||
match client
|
||||
.send_session_command(session_id.clone(), "Stop")
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
|
||||
e
|
||||
);
|
||||
// Don't fail the transfer if we can't stop the remote session
|
||||
// The user is already playing locally, so this is not critical
|
||||
}
|
||||
@@ -702,6 +832,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Capturing emitter so we can assert what `set_mode` broadcasts.
|
||||
struct CapturingEmitter {
|
||||
events: Mutex<Vec<PlayerStatusEvent>>,
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for CapturingEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
|
||||
let emitter = Arc::new(CapturingEmitter {
|
||||
events: Mutex::new(Vec::new()),
|
||||
});
|
||||
let manager = PlaybackModeManager::new(
|
||||
Arc::new(Mutex::new(None)),
|
||||
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||
);
|
||||
manager.set_event_emitter(emitter.clone());
|
||||
(manager, emitter)
|
||||
}
|
||||
|
||||
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
|
||||
/// the frontend can reconcile its mirror store to this authoritative one.
|
||||
#[test]
|
||||
fn test_set_mode_emits_change_event() {
|
||||
let (manager, emitter) = manager_with_emitter();
|
||||
|
||||
manager.set_mode(PlaybackMode::Remote {
|
||||
session_id: "sess-1".to_string(),
|
||||
});
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
manager.set_mode(PlaybackMode::Idle);
|
||||
|
||||
let events = emitter.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 3, "one event per real mode change");
|
||||
|
||||
match &events[0] {
|
||||
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
||||
assert_eq!(mode, "remote");
|
||||
assert_eq!(session_id.as_deref(), Some("sess-1"));
|
||||
}
|
||||
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
||||
}
|
||||
match &events[1] {
|
||||
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
||||
assert_eq!(mode, "local");
|
||||
assert_eq!(session_id.as_deref(), None);
|
||||
}
|
||||
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
||||
}
|
||||
match &events[2] {
|
||||
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
|
||||
assert_eq!(mode, "idle");
|
||||
assert_eq!(session_id.as_deref(), None);
|
||||
}
|
||||
other => panic!("expected PlaybackModeChanged, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||
#[test]
|
||||
fn test_set_mode_deduplicates_no_op() {
|
||||
let (manager, emitter) = manager_with_emitter();
|
||||
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
manager.set_mode(PlaybackMode::Local);
|
||||
|
||||
assert_eq!(
|
||||
emitter.events.lock().unwrap().len(),
|
||||
1,
|
||||
"repeated identical mode set emits only once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The resume position handed to a remote session is derived from a live
|
||||
/// playback position. Guards the seconds->ticks conversion and the
|
||||
/// at-the-start threshold (Bug: casting restarted the track from 0).
|
||||
@@ -709,7 +917,10 @@ mod tests {
|
||||
fn test_start_position_ticks_from_seconds() {
|
||||
// Mid-track positions convert to ticks (10M ticks per second).
|
||||
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
||||
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
|
||||
assert_eq!(
|
||||
start_position_ticks_from_seconds(123.45),
|
||||
Some(1_234_500_000)
|
||||
);
|
||||
|
||||
// At/near the start, send no resume position so the track casts from 0.
|
||||
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
||||
@@ -737,6 +948,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -768,6 +980,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -797,7 +1010,12 @@ mod tests {
|
||||
fn test_extract_all_jellyfin_ids_from_album() {
|
||||
// Simulate an album with 5 tracks - all should be extracted
|
||||
let items: Vec<MediaItem> = (1..=5)
|
||||
.map(|i| create_test_item_with_jellyfin_id(&format!("track_{}", i), &format!("jf_track_{}", i)))
|
||||
.map(|i| {
|
||||
create_test_item_with_jellyfin_id(
|
||||
&format!("track_{}", i),
|
||||
&format!("jf_track_{}", i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manager = super::PlaybackModeManager::new(
|
||||
@@ -831,7 +1049,7 @@ mod tests {
|
||||
// Mix of Jellyfin and local items - only Jellyfin items should be extracted
|
||||
let items = vec![
|
||||
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||
create_test_item_with_jellyfin_id("4", "jf_4"),
|
||||
];
|
||||
@@ -866,7 +1084,7 @@ mod tests {
|
||||
// Current item has no Jellyfin ID - should fail
|
||||
let items = vec![
|
||||
create_test_item_with_jellyfin_id("1", "jf_1"),
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_local("2"), // Local, no Jellyfin ID
|
||||
create_test_item_with_jellyfin_id("3", "jf_3"),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod reporter;
|
||||
pub mod throttle;
|
||||
pub mod sync_processor;
|
||||
pub mod throttle;
|
||||
|
||||
pub use reporter::{PlaybackReporter, PlaybackOperation, PlaybackContext};
|
||||
#[allow(unused_imports)] // Will be used when position updates are hooked
|
||||
pub use throttle::EventThrottler;
|
||||
#[allow(unused_imports)] // Will be used when sync processor is integrated
|
||||
pub use reporter::{PlaybackContext, PlaybackOperation, PlaybackReporter};
|
||||
#[allow(unused_imports)] // Will be used when sync processor is integrated
|
||||
pub use sync_processor::SyncProcessor;
|
||||
#[allow(unused_imports)] // Will be used when position updates are hooked
|
||||
pub use throttle::EventThrottler;
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
|
||||
/// Playback context information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaybackContext {
|
||||
pub context_type: String, // "container" or "single"
|
||||
pub context_type: String, // "container" or "single"
|
||||
pub context_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -65,7 +65,11 @@ impl PlaybackReporter {
|
||||
///
|
||||
/// Always updates local DB first, then attempts server sync if online.
|
||||
/// If server sync fails, operation is queued for retry.
|
||||
pub async fn report(&self, operation: PlaybackOperation, is_online: bool) -> Result<(), String> {
|
||||
pub async fn report(
|
||||
&self,
|
||||
operation: PlaybackOperation,
|
||||
is_online: bool,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlaybackReporter] Reporting operation: {:?}", operation);
|
||||
|
||||
// Always update local DB first (works offline)
|
||||
@@ -93,7 +97,11 @@ impl PlaybackReporter {
|
||||
/// Updates local database with playback info
|
||||
async fn update_local_db(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} => {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at,
|
||||
playback_context_type, playback_context_id, pending_sync)
|
||||
@@ -113,12 +121,22 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for start: {}", item_id);
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { item_id, position_ticks, is_paused: _ } |
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused: _,
|
||||
}
|
||||
| PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks, last_played_at, pending_sync)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, 1)
|
||||
@@ -133,8 +151,14 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for progress/stop: {}", item_id);
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Updated local DB for progress/stop: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackOperation::MarkPlayed { item_id } => {
|
||||
@@ -152,8 +176,14 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Updated local DB for mark played: {}", item_id);
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Updated local DB for mark played: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,34 +193,57 @@ impl PlaybackReporter {
|
||||
/// Syncs to Jellyfin server
|
||||
async fn sync_to_server(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
let client_guard = self.jellyfin_client.lock().await;
|
||||
let client = client_guard.as_ref().ok_or("JellyfinClient not initialized")?;
|
||||
let client = client_guard
|
||||
.as_ref()
|
||||
.ok_or("JellyfinClient not initialized")?;
|
||||
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, .. } => {
|
||||
client.report_playback_start(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
..
|
||||
} => {
|
||||
client
|
||||
.report_playback_start(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::info!("[PlaybackReporter] Reported start to server: {}", item_id);
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { item_id, position_ticks, is_paused } => {
|
||||
client.report_playback_progress(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
*is_paused,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
log::debug!("[PlaybackReporter] Reported progress to server: {} (paused: {})", item_id, is_paused);
|
||||
PlaybackOperation::Progress {
|
||||
item_id,
|
||||
position_ticks,
|
||||
is_paused,
|
||||
} => {
|
||||
client
|
||||
.report_playback_progress(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
*is_paused,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::debug!(
|
||||
"[PlaybackReporter] Reported progress to server: {} (paused: {})",
|
||||
item_id,
|
||||
is_paused
|
||||
);
|
||||
}
|
||||
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
client.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
).await?;
|
||||
PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
client
|
||||
.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
*position_ticks,
|
||||
None, // play_session_id
|
||||
)
|
||||
.await?;
|
||||
log::info!("[PlaybackReporter] Reported stop to server: {}", item_id);
|
||||
}
|
||||
|
||||
@@ -199,12 +252,13 @@ impl PlaybackReporter {
|
||||
// For now, report as stopped at max position
|
||||
// TODO: Fetch item runtime from DB or assume 100% completion
|
||||
let max_ticks = i64::MAX; // Temporary - should be actual runtime
|
||||
client.report_playback_stopped(
|
||||
item_id.clone(),
|
||||
max_ticks,
|
||||
None,
|
||||
).await?;
|
||||
log::info!("[PlaybackReporter] Reported mark played to server: {}", item_id);
|
||||
client
|
||||
.report_playback_stopped(item_id.clone(), max_ticks, None)
|
||||
.await?;
|
||||
log::info!(
|
||||
"[PlaybackReporter] Reported mark played to server: {}",
|
||||
item_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,13 +268,21 @@ impl PlaybackReporter {
|
||||
/// Queues operation for later sync
|
||||
async fn queue_for_sync(&self, operation: &PlaybackOperation) -> Result<(), String> {
|
||||
let (op_name, item_id, payload) = match operation {
|
||||
PlaybackOperation::Start { item_id, position_ticks, context } => {
|
||||
PlaybackOperation::Start {
|
||||
item_id,
|
||||
position_ticks,
|
||||
context,
|
||||
} => {
|
||||
let payload_data = serde_json::json!({
|
||||
"position_ticks": position_ticks,
|
||||
"context_type": context.as_ref().map(|c| &c.context_type),
|
||||
"context_id": context.as_ref().and_then(|c| c.context_id.as_ref()),
|
||||
});
|
||||
("report_playback_start", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||
(
|
||||
"report_playback_start",
|
||||
Some(item_id.clone()),
|
||||
Some(payload_data.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackOperation::Progress { .. } => {
|
||||
@@ -230,11 +292,18 @@ impl PlaybackReporter {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
PlaybackOperation::Stopped { item_id, position_ticks } => {
|
||||
PlaybackOperation::Stopped {
|
||||
item_id,
|
||||
position_ticks,
|
||||
} => {
|
||||
let payload_data = serde_json::json!({
|
||||
"position_ticks": position_ticks,
|
||||
});
|
||||
("report_playback_stopped", Some(item_id.clone()), Some(payload_data.to_string()))
|
||||
(
|
||||
"report_playback_stopped",
|
||||
Some(item_id.clone()),
|
||||
Some(payload_data.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackOperation::MarkPlayed { item_id } => {
|
||||
@@ -253,7 +322,10 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!("[PlaybackReporter] Queued operation: {}", op_name);
|
||||
|
||||
Ok(())
|
||||
@@ -269,7 +341,10 @@ impl PlaybackReporter {
|
||||
],
|
||||
);
|
||||
|
||||
self.db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
self.db_service
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::debug!("[PlaybackReporter] Marked as synced: {}", item_id);
|
||||
|
||||
Ok(())
|
||||
@@ -278,10 +353,10 @@ impl PlaybackReporter {
|
||||
/// Extracts item_id from operation
|
||||
fn get_item_id(&self, operation: &PlaybackOperation) -> Option<String> {
|
||||
match operation {
|
||||
PlaybackOperation::Start { item_id, .. } |
|
||||
PlaybackOperation::Progress { item_id, .. } |
|
||||
PlaybackOperation::Stopped { item_id, .. } |
|
||||
PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
|
||||
PlaybackOperation::Start { item_id, .. }
|
||||
| PlaybackOperation::Progress { item_id, .. }
|
||||
| PlaybackOperation::Stopped { item_id, .. }
|
||||
| PlaybackOperation::MarkPlayed { item_id } => Some(item_id.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
#![allow(dead_code)]
|
||||
#![allow(unused_imports)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
@@ -17,9 +17,9 @@ use crate::storage::db_service::RusqliteService;
|
||||
|
||||
/// Configuration for sync processor
|
||||
pub struct SyncConfig {
|
||||
pub max_retries: u32, // 5
|
||||
pub base_retry_delay_ms: u64, // 1000ms
|
||||
pub batch_size: usize, // 10 items
|
||||
pub max_retries: u32, // 5
|
||||
pub base_retry_delay_ms: u64, // 1000ms
|
||||
pub batch_size: usize, // 10 items
|
||||
}
|
||||
|
||||
impl Default for SyncConfig {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
//! through JNI calls to Kotlin code.
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::debug;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use log::debug;
|
||||
|
||||
use jni::objects::{GlobalRef, JClass, JObject, JString, JValue};
|
||||
use jni::sys::{jboolean, jdouble, jfloat, jint};
|
||||
@@ -17,7 +17,7 @@ use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
||||
use super::media::{MediaItem, MediaType};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{PlaybackReporter, EventThrottler, PlaybackOperation};
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::utils::conversions::seconds_to_ticks;
|
||||
|
||||
/// Global reference to the JavaVM for JNI callbacks
|
||||
@@ -33,10 +33,12 @@ static EVENT_EMITTER: OnceLock<SharedEventEmitter> = OnceLock::new();
|
||||
static SHARED_STATE: OnceLock<Arc<Mutex<ExoPlayerState>>> = OnceLock::new();
|
||||
|
||||
/// Global handler for media session commands from Android lockscreen/notification
|
||||
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> = OnceLock::new();
|
||||
static MEDIA_COMMAND_HANDLER: OnceLock<Arc<dyn MediaCommandHandler + Send + Sync>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Global handler for remote volume changes from Android volume buttons
|
||||
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> = OnceLock::new();
|
||||
static REMOTE_VOLUME_HANDLER: OnceLock<Arc<dyn RemoteVolumeHandler + Send + Sync>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Global player controller for autoplay decisions
|
||||
static PLAYER_CONTROLLER: OnceLock<Arc<TokioMutex<super::PlayerController>>> = OnceLock::new();
|
||||
@@ -87,9 +89,9 @@ impl DetectedCodecs {
|
||||
|
||||
/// Public function to get detected codecs (for use in repository layer)
|
||||
pub fn get_detected_codecs() -> Option<(String, String)> {
|
||||
DETECTED_CODECS.get().map(|codecs| {
|
||||
(codecs.video_codecs_string(), codecs.audio_codecs_string())
|
||||
})
|
||||
DETECTED_CODECS
|
||||
.get()
|
||||
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
|
||||
}
|
||||
|
||||
/// Trait for handling media commands from Android MediaSession.
|
||||
@@ -194,9 +196,9 @@ impl ExoPlayerBackend {
|
||||
let _ = JAVA_VM.set(vm);
|
||||
|
||||
// Store the Context as a global reference for later use
|
||||
let context_global = env
|
||||
.new_global_ref(context)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global context ref: {}", e)))?;
|
||||
let context_global = env.new_global_ref(context).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create global context ref: {}", e))
|
||||
})?;
|
||||
let _ = APP_CONTEXT.set(context_global);
|
||||
|
||||
// Store the event emitter
|
||||
@@ -217,11 +219,16 @@ impl ExoPlayerBackend {
|
||||
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to get ClassLoader: {}", e)))?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert ClassLoader: {}", e))
|
||||
})?;
|
||||
|
||||
// Load the JellyTauPlayer class using the app's class loader
|
||||
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create class name string: {}", e)))?;
|
||||
let player_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create class name string: {}", e))
|
||||
})?;
|
||||
|
||||
let player_class_obj = env
|
||||
.call_method(
|
||||
@@ -230,9 +237,13 @@ impl ExoPlayerBackend {
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&player_class_name.into())],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e)))?
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to load JellyTauPlayer class: {}", e))
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to Class: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert to Class: {}", e))
|
||||
})?;
|
||||
|
||||
// Cast to JClass for static method calls
|
||||
let player_class = JClass::from(player_class_obj);
|
||||
@@ -244,7 +255,9 @@ impl ExoPlayerBackend {
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to initialize JellyTauPlayer: {}", e))
|
||||
})?;
|
||||
|
||||
// Get the singleton instance
|
||||
let player_obj = env
|
||||
@@ -254,14 +267,21 @@ impl ExoPlayerBackend {
|
||||
"()Lcom/dtourolle/jellytau/player/JellyTauPlayer;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to get JellyTauPlayer instance: {}", e)))?
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!(
|
||||
"Failed to get JellyTauPlayer instance: {}",
|
||||
e
|
||||
))
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to convert to object: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to convert to object: {}", e))
|
||||
})?;
|
||||
|
||||
// Create a global reference to keep the player alive
|
||||
let player_ref = env
|
||||
.new_global_ref(player_obj)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create global ref: {}", e)))?;
|
||||
let player_ref = env.new_global_ref(player_obj).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create global ref: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
player_ref,
|
||||
@@ -271,16 +291,18 @@ impl ExoPlayerBackend {
|
||||
|
||||
/// Call a void method on the player with no arguments
|
||||
fn call_player_method(&self, method: &str) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
env.call_method(&self.player_ref, method, "()V", &[])
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call {}: {}", method, e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call {}: {}", method, e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,43 +336,46 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
state.is_loaded = false;
|
||||
}
|
||||
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
|
||||
|
||||
// Create JNI strings for required parameters
|
||||
let url_jstring = env
|
||||
.new_string(&url)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create URL string: {}", e)))?;
|
||||
let url_jstring = env.new_string(&url).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create URL string: {}", e))
|
||||
})?;
|
||||
|
||||
let media_id_jstring = env
|
||||
.new_string(&media_id)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media ID string: {}", e)))?;
|
||||
let media_id_jstring = env.new_string(&media_id).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create media ID string: {}", e))
|
||||
})?;
|
||||
|
||||
let title_jstring = env
|
||||
.new_string(&title)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create title string: {}", e)))?;
|
||||
let title_jstring = env.new_string(&title).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create title string: {}", e))
|
||||
})?;
|
||||
|
||||
// Create JNI strings for optional parameters (null if None)
|
||||
let artist_jstring = match &artist {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artist string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create artist string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let album_jstring = match &album {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create album string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create album string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let artwork_jstring = match &artwork_url {
|
||||
Some(a) => Some(env.new_string(a)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create artwork string: {}", e)))?),
|
||||
Some(a) => Some(env.new_string(a).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create artwork string: {}", e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
@@ -376,16 +401,16 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
MediaType::Video => "video",
|
||||
MediaType::Audio => "audio",
|
||||
};
|
||||
let media_type_jstring = env
|
||||
.new_string(media_type_str)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create media type string: {}", e)))?;
|
||||
let media_type_jstring = env.new_string(media_type_str).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create media type string: {}", e))
|
||||
})?;
|
||||
|
||||
// Serialize subtitles to JSON for passing to Kotlin
|
||||
let subtitles_json = serde_json::to_string(&media.subtitles)
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let subtitles_jstring = env
|
||||
.new_string(&subtitles_json)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e)))?;
|
||||
let subtitles_json =
|
||||
serde_json::to_string(&media.subtitles).unwrap_or_else(|_| "[]".to_string());
|
||||
let subtitles_jstring = env.new_string(&subtitles_json).map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to create subtitles JSON string: {}", e))
|
||||
})?;
|
||||
|
||||
// Call loadWithMetadata for MediaSession support (lockscreen controls)
|
||||
debug!("[Android] Loading media: url={}, id={}, title={}, artist={:?}, album={:?}, duration_ms={}, type={}, subtitles={}",
|
||||
@@ -414,7 +439,10 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
env.exception_describe().ok();
|
||||
env.exception_clear().ok();
|
||||
}
|
||||
return Err(PlayerError::playback_failed(format!("Failed to call loadWithMetadata: {}", e)));
|
||||
return Err(PlayerError::playback_failed(format!(
|
||||
"Failed to call loadWithMetadata: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
debug!("[Android] Successfully called loadWithMetadata");
|
||||
@@ -443,9 +471,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -465,9 +493,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -502,9 +530,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
}
|
||||
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -516,15 +544,17 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(stream_index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setAudioTrack: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
let vm = JAVA_VM.get().ok_or_else(|| {
|
||||
PlayerError::playback_failed("JavaVM not initialized")
|
||||
})?;
|
||||
let vm = JAVA_VM
|
||||
.get()
|
||||
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
|
||||
|
||||
let mut env = vm
|
||||
.attach_current_thread()
|
||||
@@ -539,7 +569,9 @@ impl PlayerBackend for ExoPlayerBackend {
|
||||
"(I)V",
|
||||
&[JValue::Int(index)],
|
||||
)
|
||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
PlayerError::playback_failed(format!("Failed to call setSubtitleTrack: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -603,7 +635,11 @@ fn report_android_progress(position: f64) {
|
||||
if !state.state.is_playing() {
|
||||
return;
|
||||
}
|
||||
match state.current_media.as_ref().and_then(|m| m.jellyfin_id().map(|s| s.to_string())) {
|
||||
match state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
{
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
}
|
||||
@@ -664,10 +700,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
state: JString,
|
||||
media_id: JString,
|
||||
) {
|
||||
let state_str: String = env
|
||||
.get_string(&state)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||
|
||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
@@ -769,7 +802,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
@@ -779,7 +816,11 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
// Log queue state after advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||
|
||||
@@ -1002,7 +1043,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlayer class using the app's class loader
|
||||
let player_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
let player_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlayer")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let player_class_obj = env
|
||||
@@ -1036,13 +1078,8 @@ fn start_playback_service() -> Result<(), String> {
|
||||
}
|
||||
|
||||
// Call startPlaybackService() on the player instance
|
||||
env.call_method(
|
||||
&player_obj,
|
||||
"startPlaybackService",
|
||||
"()V",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
env.call_method(&player_obj, "startPlaybackService", "()V", &[])
|
||||
.map_err(|e| format!("Failed to start playback service: {}", e))?;
|
||||
|
||||
log::info!("[Android] JellyTauPlaybackService start requested");
|
||||
Ok(())
|
||||
@@ -1056,7 +1093,10 @@ fn start_playback_service() -> Result<(), String> {
|
||||
/// @param initial_volume Initial volume level (0-100)
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||
log::info!("[Android] Enabling remote volume control (volume={})", initial_volume);
|
||||
log::info!(
|
||||
"[Android] Enabling remote volume control (volume={})",
|
||||
initial_volume
|
||||
);
|
||||
|
||||
// Ensure the playback service is started first
|
||||
start_playback_service()?;
|
||||
@@ -1078,7 +1118,8 @@ pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlaybackService class using the app's class loader
|
||||
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
let service_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let service_class_obj = env
|
||||
@@ -1146,7 +1187,8 @@ pub fn disable_remote_volume() -> Result<(), String> {
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
// Load the JellyTauPlaybackService class using the app's class loader
|
||||
let service_class_name = env.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
let service_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let service_class_obj = env
|
||||
@@ -1273,6 +1315,66 @@ pub fn update_lockscreen_metadata(meta: &LockscreenMetadata) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the base position offset (seconds) on the lockscreen MediaSession.
|
||||
///
|
||||
/// Calls `JellyTauPlaybackService.setPositionOffset(double)`. No-op if the
|
||||
/// service isn't running yet, so it's safe to call unconditionally.
|
||||
pub fn set_position_offset(offset_seconds: f64) -> Result<(), String> {
|
||||
let vm = JAVA_VM.get().ok_or("JavaVM not initialized")?;
|
||||
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
|
||||
|
||||
let context = APP_CONTEXT.get().ok_or("Context not initialized")?;
|
||||
|
||||
let class_loader = env
|
||||
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
|
||||
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
|
||||
.l()
|
||||
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
|
||||
|
||||
let service_class_name = env
|
||||
.new_string("com.dtourolle.jellytau.player.JellyTauPlaybackService")
|
||||
.map_err(|e| format!("Failed to create class name string: {}", e))?;
|
||||
|
||||
let service_class_obj = env
|
||||
.call_method(
|
||||
&class_loader,
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[JValue::Object(&service_class_name.into())],
|
||||
)
|
||||
.map_err(|e| format!("Failed to load JellyTauPlaybackService class: {}", e))?
|
||||
.l()
|
||||
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
|
||||
|
||||
let service_class = JClass::from(service_class_obj);
|
||||
|
||||
let service_obj = env
|
||||
.call_static_method(
|
||||
&service_class,
|
||||
"getInstance",
|
||||
"()Lcom/dtourolle/jellytau/player/JellyTauPlaybackService;",
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to get service instance: {}", e))?
|
||||
.l()
|
||||
.map_err(|e| format!("Failed to convert to object: {}", e))?;
|
||||
|
||||
// Service not running yet - nothing to offset.
|
||||
if service_obj.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
env.call_method(
|
||||
&service_obj,
|
||||
"setPositionOffset",
|
||||
"(D)V",
|
||||
&[JValue::Double(offset_seconds)],
|
||||
)
|
||||
.map_err(|e| format!("Failed to set position offset: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stub implementations for non-Android platforms
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub fn enable_remote_volume(_initial_volume: i32) -> Result<(), String> {
|
||||
|
||||
@@ -1,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,
|
||||
@@ -370,6 +380,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -428,6 +439,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -480,6 +492,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
|
||||
@@ -124,6 +124,21 @@ pub enum PlayerStatusEvent {
|
||||
/// All active controllable sessions from Jellyfin
|
||||
sessions: Vec<crate::jellyfin::client::SessionInfo>,
|
||||
},
|
||||
/// The authoritative playback mode changed in the Rust backend.
|
||||
///
|
||||
/// The Rust `PlaybackModeManager` is the single source of truth for which
|
||||
/// device playback commands route to (local vs a remote session). The
|
||||
/// frontend keeps a mirror store for the UI; without this event that mirror
|
||||
/// drifts out of sync (e.g. a mode transition happens inside a transfer or a
|
||||
/// local stop that the frontend never learns about), and controls then route
|
||||
/// to the wrong device — the classic "it keeps playing on the remote" bug.
|
||||
/// The frontend reconciles its store to this payload whenever it fires.
|
||||
PlaybackModeChanged {
|
||||
/// New mode: "local", "remote", or "idle".
|
||||
mode: String,
|
||||
/// Session id when `mode == "remote"`, otherwise `None`.
|
||||
session_id: Option<String>,
|
||||
},
|
||||
/// The user asked to disconnect from the remote session and resume locally.
|
||||
///
|
||||
/// Emitted when the lockscreen Stop button is pressed while casting. The
|
||||
|
||||
@@ -67,9 +67,16 @@ pub struct MediaItem {
|
||||
/// Artists as array of strings (fallback when artist_items not available)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
/// Primary image tag for artwork
|
||||
/// Primary image tag for artwork.
|
||||
///
|
||||
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
|
||||
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier the frontend resolves to a URL — replaces
|
||||
/// `primary_image_tag`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_id: Option<String>,
|
||||
/// Item type (Audio, Movie, Episode, etc.)
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<String>,
|
||||
@@ -138,8 +145,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 +162,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(),
|
||||
}
|
||||
}
|
||||
@@ -343,6 +352,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -378,6 +388,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -412,6 +423,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -446,6 +458,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -487,6 +500,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -521,6 +535,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(120.0),
|
||||
|
||||
+417
-90
@@ -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,11 +1002,14 @@ impl PlayerController {
|
||||
// Check if auto-play episode limit is reached
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
current_episode: next_ep.0, // Repository MediaItem
|
||||
next_episode: next_ep.1,
|
||||
countdown_seconds: settings.countdown_seconds,
|
||||
auto_advance: settings.enabled && !limit_reached,
|
||||
@@ -993,10 +1048,16 @@ impl PlayerController {
|
||||
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||
let stale_reason = self.take_end_reason();
|
||||
if stale_reason.is_some() {
|
||||
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||
debug!(
|
||||
"[PlayerController] Cleared stale end_reason for video: {:?}",
|
||||
stale_reason
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||
log::info!(
|
||||
"[PlayerController] on_video_playback_ended: item_id={}",
|
||||
item_id
|
||||
);
|
||||
|
||||
// Check sleep timer state
|
||||
let timer_mode = {
|
||||
@@ -1035,7 +1096,10 @@ impl PlayerController {
|
||||
let next_ep_result = match self.fetch_next_episode_for_item(item_id, &repo).await {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
warn!("[PlayerController] Next-episode lookup failed for {}: {}", item_id, e);
|
||||
warn!(
|
||||
"[PlayerController] Next-episode lookup failed for {}: {}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -1044,7 +1108,10 @@ impl PlayerController {
|
||||
|
||||
let limit_reached = self.increment_autoplay_count();
|
||||
if limit_reached {
|
||||
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||
debug!(
|
||||
"[PlayerController] Auto-play episode limit reached ({} episodes)",
|
||||
settings.max_episodes
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
@@ -1077,11 +1144,18 @@ impl PlayerController {
|
||||
&self,
|
||||
item_id: &str,
|
||||
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||
) -> Result<
|
||||
Option<(
|
||||
crate::repository::types::MediaItem,
|
||||
crate::repository::types::MediaItem,
|
||||
)>,
|
||||
String,
|
||||
> {
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
|
||||
// Get the current item details from repository
|
||||
let current_repo_item = repo.get_item(item_id)
|
||||
let current_repo_item = repo
|
||||
.get_item(item_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||
|
||||
@@ -1089,7 +1163,9 @@ impl PlayerController {
|
||||
let season_id = match ¤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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,6 +1397,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -1332,8 +1433,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 +1452,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 +1491,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 +1516,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 +1550,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 +1565,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 +1598,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 +1629,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 +1643,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 +1684,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 +1723,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 +1752,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 +1777,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 +1799,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 +1845,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
|
||||
@@ -1942,6 +2178,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
name: format!("Episode {}", index),
|
||||
item_type: "Episode".to_string(),
|
||||
kind: crate::domain::MediaKind::Episode,
|
||||
is_folder: false,
|
||||
server_id: "server".to_string(),
|
||||
parent_id: Some("season1".to_string()),
|
||||
@@ -1949,11 +2186,13 @@ mod tests {
|
||||
overview: None,
|
||||
genres: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
production_year: None,
|
||||
premiere_date: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -1990,7 +2229,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 +2241,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 +2358,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 +2391,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,10 +129,9 @@ impl MpvBackend {
|
||||
message: format!("Failed to configure MPV audio-display: {:?}", e),
|
||||
})?;
|
||||
|
||||
mpv.set_property("video", "no")
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
@@ -191,7 +196,11 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PlaybackRestart => {
|
||||
debug!("[MpvBackend] Playback started/resumed");
|
||||
|
||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||
let media_id = state
|
||||
.lock_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
@@ -203,11 +212,16 @@ impl MpvBackend {
|
||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||
// Handle pause state changes
|
||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||
let media_id = state
|
||||
.lock_safe()
|
||||
.current_media
|
||||
.as_ref()
|
||||
.map(|m| m.id.clone());
|
||||
|
||||
if let Some(emitter) = &event_emitter {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: if is_paused { "paused" } else { "playing" }.to_string(),
|
||||
state: if is_paused { "paused" } else { "playing" }
|
||||
.to_string(),
|
||||
media_id,
|
||||
});
|
||||
}
|
||||
@@ -303,14 +317,18 @@ impl MpvBackend {
|
||||
}
|
||||
|
||||
// Check if we're playing for progress reporting
|
||||
let is_paused = mpv_for_position.get_property::<bool>("pause").unwrap_or(true);
|
||||
let is_paused = mpv_for_position
|
||||
.get_property::<bool>("pause")
|
||||
.unwrap_or(true);
|
||||
|
||||
// Only report progress to server when playing (not paused)
|
||||
if !is_paused {
|
||||
// Throttled progress reporting (every 30s)
|
||||
let jellyfin_id = {
|
||||
let state = state_for_position.lock_safe();
|
||||
state.current_media.as_ref()
|
||||
state
|
||||
.current_media
|
||||
.as_ref()
|
||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||
};
|
||||
|
||||
@@ -333,8 +351,14 @@ impl MpvBackend {
|
||||
};
|
||||
|
||||
match reporter_instance.report(operation, true).await {
|
||||
Ok(_) => debug!("[MpvBackend] Reported progress for {}", item_id_clone),
|
||||
Err(e) => warn!("[MpvBackend] Failed to report progress: {}", e),
|
||||
Ok(_) => debug!(
|
||||
"[MpvBackend] Reported progress for {}",
|
||||
item_id_clone
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[MpvBackend] Failed to report progress: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -468,9 +492,7 @@ impl PlayerBackend for MpvBackend {
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.mpv
|
||||
.get_property::<f64>("time-pos")
|
||||
.unwrap_or(0.0)
|
||||
self.mpv.get_property::<f64>("time-pos").unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
|
||||
@@ -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(),
|
||||
@@ -544,6 +551,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -230,6 +242,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -260,6 +273,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(7200.0),
|
||||
@@ -294,7 +308,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
manager.current(),
|
||||
MediaSessionType::Audio { is_active: true, .. }
|
||||
MediaSessionType::Audio {
|
||||
is_active: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(manager.should_show_miniplayer());
|
||||
|
||||
@@ -307,7 +324,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 +350,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());
|
||||
@@ -321,6 +323,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Video".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(100.0),
|
||||
|
||||
+703
-173
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user