Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9350c949 | ||
|
|
d01c1216b8 | ||
|
|
fb967433f0 | ||
|
|
ee584aced2 | ||
|
|
eb76c96e94 | ||
|
|
c3ead64748 | ||
|
|
742ad88a29 | ||
|
|
d4e2cd120c | ||
|
|
c543f90ad3 | ||
|
|
589f08b873 | ||
|
|
e2c9d68311 | ||
|
|
57b24f8c74 | ||
|
|
6391720d23 | ||
|
|
90f03dd142 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -121,6 +121,64 @@ jobs:
|
||||
path: dist/linux/
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
|
||||
# baked into the builder image. No toolchain installs here — the image has
|
||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cache/cargo-xwin
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-windows-
|
||||
|
||||
- name: Cache Node dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Set app version from tag
|
||||
run: |
|
||||
# On a tag build the tag is the single source of truth for the version.
|
||||
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
|
||||
grep '"version"' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
|
||||
- name: List Windows artifacts
|
||||
run: ls -lah dist/windows/
|
||||
|
||||
- name: Upload Windows build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: dist/windows/
|
||||
retention-days: 30
|
||||
|
||||
build-android:
|
||||
name: Build Android
|
||||
runs-on: linux/amd64
|
||||
@@ -239,7 +297,7 @@ jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: linux/amd64
|
||||
needs: [build-linux, build-android]
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
@@ -259,6 +317,12 @@ jobs:
|
||||
name: jellytau-linux
|
||||
path: artifacts/linux/
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: artifacts/windows/
|
||||
|
||||
- name: Download Android artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -277,6 +341,9 @@ jobs:
|
||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
||||
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Windows" >> release_notes.md
|
||||
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Android" >> release_notes.md
|
||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||
@@ -358,7 +425,7 @@ jobs:
|
||||
fi
|
||||
echo "Release id=$RELEASE_ID"
|
||||
|
||||
for f in artifacts/android/* artifacts/linux/*; do
|
||||
for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "⬆️ Uploading $(basename "$f")"
|
||||
curl -fsS -X POST \
|
||||
|
||||
@@ -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,297 @@
|
||||
# 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`.
|
||||
|
||||
> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
|
||||
> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
|
||||
> CI workflow step. Every build, test, and packaging **tool** must already live
|
||||
> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
|
||||
> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
|
||||
> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
|
||||
> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
|
||||
> it from CI — do not install it at job time. This keeps builds reproducible and
|
||||
> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
|
||||
>
|
||||
> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
|
||||
> a violation — that's project dependencies, not a toolchain. The rule is about
|
||||
> system tools, not npm/bun/cargo *packages* declared by the project.
|
||||
|
||||
## 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
|
||||
|
||||
### 🔴 Bug fixes: failing test FIRST, then the fix
|
||||
|
||||
When fixing a bug, **write a test that reproduces it and watch it fail before
|
||||
touching the fix.** Red → green, in that order:
|
||||
|
||||
1. Write a test that exercises the broken behavior and **run it — it must fail**,
|
||||
proving the test actually catches the bug (a test that passes before the fix
|
||||
proves nothing).
|
||||
2. Apply the fix.
|
||||
3. Re-run — the test now passes, and so does the rest of the suite.
|
||||
|
||||
Never fix first and backfill the test afterward: a test written against
|
||||
already-fixed code can pass for the wrong reason and silently fails to guard the
|
||||
regression. If the logic is buried in a component, extract the pure part into a
|
||||
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
|
||||
pattern as `TrackList.logic.test.ts`.
|
||||
|
||||
```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
|
||||
```
|
||||
+31
@@ -1,4 +1,11 @@
|
||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
||||
#
|
||||
# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
|
||||
# the unified registry builder image, which carries every packaging tool. Declared
|
||||
# here (before the first FROM) so it's in scope for those stages' FROM lines.
|
||||
# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
|
||||
ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -108,6 +115,30 @@ RUN cd src-tauri && cargo fetch && cd .. && \
|
||||
bun run tauri android build --apk true && \
|
||||
echo "APK build complete!"
|
||||
|
||||
# Desktop packaging stages build FROM the unified registry builder image (see the
|
||||
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
|
||||
# (rpm/file for Linux, mingw-w64 + nsis + the x86_64-pc-windows-gnu rust target
|
||||
# for Windows). ONE source of dependency truth, shared with CI — no per-stage
|
||||
# apt/rustup here.
|
||||
|
||||
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
|
||||
# Thin layer over the builder — the actual build runs at container-run time on
|
||||
# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
|
||||
# matching the `dev` service model. Run standalone with:
|
||||
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" <img> \
|
||||
# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
FROM ${BUILDER_IMAGE} AS desktop-linux-build
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
||||
|
||||
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
||||
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
|
||||
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
||||
# exe-only. Build runs at container-run time like above.
|
||||
FROM ${BUILDER_IMAGE} AS windows-cross
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
|
||||
|
||||
# Final output stage
|
||||
FROM ubuntu:24.04 AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# JellyTau Arch Linux package builder.
|
||||
#
|
||||
# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
|
||||
# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
|
||||
# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
|
||||
#
|
||||
# docker build -f Dockerfile.arch -t jellytau-arch .
|
||||
# docker run --rm -v "$PWD/dist:/out" jellytau-arch
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm \
|
||||
base-devel git sudo \
|
||||
rust cargo nodejs \
|
||||
webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
|
||||
libsoup3 pkgconf openssl \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# Bun is not in the official repos; install the upstream binary.
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Non-root build user with passwordless sudo for makepkg's dependency step.
|
||||
RUN useradd -m builder && \
|
||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
|
||||
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN chown -R builder:builder /app
|
||||
|
||||
USER builder
|
||||
ENV OUTPUT_DIR=/out
|
||||
RUN mkdir -p /out
|
||||
VOLUME ["/out"]
|
||||
|
||||
# Default: build the package. Output lands in /out (mount it to collect the pkg).
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
|
||||
+33
-1
@@ -1,5 +1,9 @@
|
||||
# JellyTau Builder Image
|
||||
# Pre-built image with all dependencies for building and testing
|
||||
# Pre-built image with all dependencies for building, testing, and packaging:
|
||||
# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
|
||||
# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
|
||||
# Arch packages build in a separate archlinux image (Dockerfile.arch) since
|
||||
# makepkg is Arch-specific.
|
||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
||||
|
||||
FROM ubuntu:24.04
|
||||
@@ -83,6 +87,34 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
# Set NDK environment variable
|
||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||
# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
|
||||
# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
|
||||
rpm \
|
||||
file \
|
||||
# Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
|
||||
# clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
|
||||
# C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
|
||||
# nsis = installer generator.
|
||||
clang \
|
||||
lld \
|
||||
llvm \
|
||||
nsis \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
|
||||
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
|
||||
# so provide it as a symlink.
|
||||
&& ln -sf /usr/bin/clang /usr/local/bin/clang-cl
|
||||
|
||||
# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
|
||||
RUN . $HOME/.cargo/env && \
|
||||
rustup target add x86_64-pc-windows-msvc && \
|
||||
cargo install --locked cargo-xwin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -33,6 +33,58 @@ services:
|
||||
ports:
|
||||
- "5172:5172" # In case you want to run dev server
|
||||
|
||||
# Linux desktop packages - deb + rpm + pacman into ./dist
|
||||
desktop-linux-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: desktop-linux-build
|
||||
args:
|
||||
# Defaults to the registry builder (Dockerfile's ARG). Point at a locally
|
||||
# built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-desktop-linux-build
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
|
||||
# Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
|
||||
arch-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.arch
|
||||
container_name: jellytau-arch-build
|
||||
volumes:
|
||||
- ./dist:/out
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/out
|
||||
|
||||
# Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
|
||||
# ./dist. Override WIN_BUNDLES=none for exe-only.
|
||||
windows-cross:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: windows-cross
|
||||
args:
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-windows-cross
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
- WIN_BUNDLES=${WIN_BUNDLES:-nsis}
|
||||
command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
|
||||
|
||||
# Development container - for interactive development
|
||||
dev:
|
||||
build:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Desktop packaging (Linux, Arch, Windows)
|
||||
|
||||
How to produce distributable desktop packages for JellyTau. All three flows can
|
||||
run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
||||
|
||||
## One builder image (shared with CI)
|
||||
|
||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||
installs.
|
||||
|
||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||
rebuild.
|
||||
|
||||
**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
|
||||
first, or the packaging flows use the stale registry image:
|
||||
|
||||
```bash
|
||||
scripts/build-builder-image.sh # build + push :latest to the registry
|
||||
# ...or iterate locally without pushing:
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||
```
|
||||
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
|
||||
| Target | Format | Docker command | Functional? |
|
||||
|--------|--------|----------------|-------------|
|
||||
| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
|
||||
| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
|
||||
| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
|
||||
|
||||
## Linux: deb + rpm
|
||||
|
||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
||||
|
||||
```bash
|
||||
bun run docker:build:linux # deb + rpm -> ./dist
|
||||
# or, on a host with the Tauri Linux deps installed:
|
||||
BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
|
||||
```
|
||||
|
||||
Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
|
||||
transcoded video). The deb/rpm declare these.
|
||||
|
||||
> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
|
||||
> add it to `BUNDLES`.
|
||||
|
||||
## Arch Linux: pacman package
|
||||
|
||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
||||
|
||||
```bash
|
||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||
```
|
||||
|
||||
The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
|
||||
publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
|
||||
|
||||
`makepkg` refuses to run as root, so the Docker stage builds as a non-root
|
||||
`builder` user. Because the image `COPY`s the source at build time, the
|
||||
`arch-build` compose service does **not** bind-mount the repo — rebuild the image
|
||||
to pick up source changes.
|
||||
|
||||
## Windows: NSIS installer cross-compiled from Linux
|
||||
|
||||
Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
|
||||
cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
|
||||
Video plays via WebView2 and audio via the webview `<audio>` backend. See
|
||||
[build-windows.md](build-windows.md) for the full explanation.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
The Docker `windows-cross` stage is a thin layer over the builder, which carries
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
|
||||
Cross-compilation is Tauri's "last resort" path (less tested than building on
|
||||
Windows); a `windows-latest` CI job is the fallback if it misbehaves.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Windows build
|
||||
|
||||
JellyTau targets Linux and Android primarily, but a working Windows build —
|
||||
including an **NSIS installer cross-compiled from Linux** — is produced by the
|
||||
Docker tooling. It is not yet a first-class release target (no code signing / CI
|
||||
job / SMTC lockscreen), but it runs and plays media.
|
||||
|
||||
## How playback works on Windows
|
||||
|
||||
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
|
||||
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
|
||||
## Cross-compiling from Linux (MSVC + cargo-xwin)
|
||||
|
||||
We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
|
||||
the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
|
||||
[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
|
||||
CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
|
||||
officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
|
||||
Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
|
||||
> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
|
||||
> gates `--bundles` by the host OS unless it recognizes a real Windows build.
|
||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
Or directly on a host that has the toolchain:
|
||||
|
||||
```bash
|
||||
scripts/build-windows-cross.sh # nsis installer + exe
|
||||
WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
|
||||
```
|
||||
|
||||
Under the hood the build runs:
|
||||
|
||||
```bash
|
||||
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `.exe` — `src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
|
||||
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
|
||||
|
||||
(both copied to `./dist` when `OUTPUT_DIR` is set).
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
|
||||
than building on Windows. If it misbehaves, a `windows-latest` CI job or a
|
||||
Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
|
||||
- **Code signing is not wired up** — the installer is unsigned, so Windows
|
||||
SmartScreen will warn on first run.
|
||||
|
||||
## Outstanding for a first-class Windows release
|
||||
|
||||
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
|
||||
path.
|
||||
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
|
||||
frontend; streaming works today.
|
||||
3. Code signing + a Windows packaging CI job.
|
||||
+110
-4
@@ -37,7 +37,7 @@ For a narrative overview of the system design, see
|
||||
| UR-024 | View recently added content on server | Medium | Done |
|
||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
||||
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
|
||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||
@@ -50,6 +50,26 @@ 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 |
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -81,10 +101,15 @@ External system integrations and platform-specific implementations.
|
||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||||
| 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 +148,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
|
||||
|
||||
@@ -161,7 +187,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
|
||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||
@@ -180,6 +206,42 @@ 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 |
|
||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,7 +260,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 +290,25 @@ 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 |
|
||||
| UR-058 | - | DR-087 |
|
||||
|
||||
---
|
||||
|
||||
@@ -295,6 +376,28 @@ 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 | Done |
|
||||
| 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 |
|
||||
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
|
||||
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
|
||||
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
|
||||
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
|
||||
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
|
||||
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
||||
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
||||
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
||||
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
|
||||
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
|
||||
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
|
||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -312,6 +415,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 | Done |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,171 @@
|
||||
# Spec: Audio equalizer
|
||||
|
||||
**Status:** Accepted
|
||||
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
||||
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||
**Supersedes / revises:** —
|
||||
|
||||
## Summary
|
||||
|
||||
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
|
||||
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
|
||||
gains, from a new block in Settings › Audio. On Linux the gains apply live via
|
||||
MPV's audio-filter chain; the settings persist and re-apply on the next track
|
||||
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
|
||||
no-op for now (documented parity gap, same as those three features).
|
||||
|
||||
## Motivation
|
||||
|
||||
UR-027 is one of the few still-unbuilt audio features. The audio-settings
|
||||
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
|
||||
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
|
||||
› Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
|
||||
ride that pipeline. The equalizer is the same shape: N more fields on
|
||||
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
|
||||
settings panel. No new command, no new state machine.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
|
||||
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
|
||||
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
|
||||
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
|
||||
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
|
||||
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
|
||||
|
||||
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
|
||||
in Rust because a preset is a set of band gains defined *by the band layout*,
|
||||
which is an engine property. The frontend only ever names a preset and renders
|
||||
the resulting gains; it never defines them.
|
||||
|
||||
## Design
|
||||
|
||||
### `AudioSettings` (Rust, `settings.rs`)
|
||||
|
||||
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
|
||||
struct attribute):
|
||||
|
||||
```rust
|
||||
/// Equalizer enabled. When false, no `af` EQ filter is applied.
|
||||
pub equalizer_enabled: bool,
|
||||
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
|
||||
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
|
||||
pub equalizer_bands: Vec<f32>,
|
||||
```
|
||||
|
||||
Fixed 10-band ISO layout (domain constant in `settings.rs`):
|
||||
|
||||
```rust
|
||||
pub const EQ_BANDS: [f32; 10] =
|
||||
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||
```
|
||||
|
||||
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
|
||||
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
|
||||
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
|
||||
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
|
||||
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
|
||||
|
||||
### Presets (Rust, `settings.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
|
||||
|
||||
impl EqPreset {
|
||||
/// The 10-band gain curve (dB) for this preset.
|
||||
pub fn gains(&self) -> [f32; 10] { /* table */ }
|
||||
}
|
||||
```
|
||||
|
||||
Preset selection is a *frontend* convenience: tapping a chip sets
|
||||
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
|
||||
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
|
||||
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
|
||||
the numbers. (If exposing the whole table is awkward through specta, expose
|
||||
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
|
||||
|
||||
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
|
||||
|
||||
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
|
||||
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
|
||||
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
|
||||
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
|
||||
filter. Errors map to `PlayerError` like the gapless code.
|
||||
|
||||
### No new persistence table
|
||||
|
||||
`AudioSettings` is already round-tripped by the frontend settings store and
|
||||
re-pushed via `player_set_audio_settings` on change and on load. The two new
|
||||
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
||||
|
||||
### Wire summary
|
||||
|
||||
- Command names unchanged: `player_set_audio_settings`,
|
||||
`player_get_audio_settings` (now carry the EQ fields).
|
||||
- New (optional) read-only command for preset curves — kebab n/a (it's a
|
||||
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
|
||||
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
||||
- Per-track or per-library EQ profiles — one global profile only.
|
||||
- Automatic loudness/room correction; only manual bands + presets.
|
||||
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||
the missing `with_crossfade_clamped` call.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings › Audio has an Equalizer block: enable toggle, preset chips, 10
|
||||
band sliders with live dB readouts, instant-apply (no Save button).
|
||||
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
|
||||
band switches the highlighted preset to "Custom" (frontend-only label).
|
||||
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
|
||||
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
|
||||
and app restart; disabling clears the filter without affecting other audio.
|
||||
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
|
||||
clamps out-of-range gains and pads/truncates band length; serialization
|
||||
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
|
||||
preset returns a 10-length curve; Flat is all zeros.
|
||||
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
|
||||
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
|
||||
expected band array; editing a band flips the label to Custom; enable toggle
|
||||
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
|
||||
|
||||
## TRACES
|
||||
|
||||
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
|
||||
- MPV EQ filter application: `UR-027 | IR-020`
|
||||
- Settings EQ UI block: `UR-027 | DR-030`
|
||||
- Preset-curve command: `UR-027 | DR-030`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session is active in this repo (it has touched
|
||||
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
|
||||
build scripts, and the Rust build is currently broken by its
|
||||
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
|
||||
anything you didn't write; keep EQ changes isolated to `settings.rs`,
|
||||
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
|
||||
`commands/player/settings.rs`, and the settings page.
|
||||
- Mirror the volume-normalization block in the settings page for the toggle +
|
||||
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
|
||||
MPV property handling.
|
||||
- Confirm the exact MPV filter name available in the linked libmpv
|
||||
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
|
||||
string; gate cleanly if unavailable.
|
||||
@@ -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
|
||||
|
||||
|
||||
+2875
-606
File diff suppressed because it is too large
Load Diff
+625
-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,387 @@ 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 — a series' season list, a Home carousel (§5B.5), etc. —
|
||||
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. Should an episode ever arrive without a `seriesId` (deep link, stale
|
||||
cache), the bare Episode page renders as a fallback and links back to its parent
|
||||
series and season by title so the user is never stranded.
|
||||
|
||||
### 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.
|
||||
|
||||
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||
|
||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
|
||||
item; playback is the deliberate, second gesture.
|
||||
|
||||
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|
||||
|-----------|-------------|---------------------------|
|
||||
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
|
||||
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
|
||||
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
|
||||
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
|
||||
|
||||
Rationale and rules:
|
||||
|
||||
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
|
||||
jumped straight into the player, which made it easy to lose your place in a
|
||||
half-watched item or start a stream you only meant to inspect. Tap now lands
|
||||
on the detail/focus page, where Play is an explicit button.
|
||||
- **Long-press is the shortcut for "just play it."** It surfaces a native
|
||||
confirm (`Play "<name>" now?`) before starting playback, so an accidental
|
||||
hold never blows away a resume position silently.
|
||||
- **The long-press must not fight the carousel.** Detection cancels if the
|
||||
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
|
||||
to scroll never triggers play.
|
||||
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
|
||||
Focus View, never a bare Episode page, so the series context loads.
|
||||
|
||||
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
|
||||
detection) so any surface can opt in; today the Home carousels are the only
|
||||
opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
||||
|
||||
---
|
||||
|
||||
## 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 +918,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 +1111,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 +1181,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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.15",
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
@@ -24,11 +25,18 @@
|
||||
"android:dev": "./scripts/build-and-deploy.sh",
|
||||
"android:check": "./scripts/check-android.sh",
|
||||
"android:logs": "./scripts/logcat.sh",
|
||||
"desktop:build:linux": "./scripts/build-desktop-linux.sh",
|
||||
"desktop:build:arch": "./scripts/build-arch.sh",
|
||||
"desktop:build:windows": "./scripts/build-windows-cross.sh",
|
||||
"docker:build:linux": "docker compose run --rm desktop-linux-build",
|
||||
"docker:build:arch": "docker compose run --rm arch-build",
|
||||
"docker:build:windows": "docker compose run --rm windows-cross",
|
||||
"clean": "./scripts/clean.sh",
|
||||
"tauri": "tauri",
|
||||
"traces": "bun run scripts/extract-traces.ts",
|
||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
|
||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
|
||||
#
|
||||
# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
|
||||
#
|
||||
# This PKGBUILD builds from the local source tree by default (see the `dev`
|
||||
# convenience below), which is what scripts/build-arch.sh uses inside the Arch
|
||||
# Docker stage. For AUR distribution, replace the `source=()` line with a release
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.0.18
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
url="https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
license=('MIT')
|
||||
# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
|
||||
depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
|
||||
makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
|
||||
options=('!strip' '!lto')
|
||||
|
||||
# Populated from the working tree by scripts/build-arch.sh (SRC env var).
|
||||
_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
|
||||
|
||||
build() {
|
||||
cd "$_srcdir"
|
||||
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
|
||||
bun install --frozen-lockfile || bun install
|
||||
bun run build
|
||||
# Only the raw binary is needed; packaging is done in package() below so we
|
||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||
(cd src-tauri && cargo build --release --locked)
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$_srcdir"
|
||||
|
||||
install -Dm755 "src-tauri/target/release/jellytau" \
|
||||
"$pkgdir/usr/bin/jellytau"
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 "packaging/arch/jellytau.desktop" \
|
||||
"$pkgdir/usr/share/applications/jellytau.desktop"
|
||||
|
||||
# Icons (hicolor)
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=JellyTau
|
||||
Comment=A cross-platform Jellyfin client
|
||||
Exec=jellytau
|
||||
Icon=jellytau
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Player;Audio;Video;
|
||||
StartupWMClass=jellytau
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
|
||||
#
|
||||
# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
|
||||
# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
|
||||
# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
|
||||
# and refuses to run as root, so run it as a non-root user with sudo for deps.
|
||||
#
|
||||
# Usage (typically inside the arch-build Docker stage as a non-root user):
|
||||
# scripts/build-arch.sh
|
||||
# OUTPUT_DIR=/app/dist scripts/build-arch.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT/packaging/arch"
|
||||
|
||||
echo "🏛️ Building JellyTau Arch package"
|
||||
echo "=================================="
|
||||
|
||||
# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
|
||||
export JELLYTAU_SRC="$REPO_ROOT"
|
||||
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
|
||||
|
||||
# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
|
||||
makepkg -sf --noconfirm
|
||||
|
||||
echo ""
|
||||
echo "✅ Built Arch package(s):"
|
||||
ls -1 ./*.pkg.tar.zst
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Build Linux desktop packages (deb + rpm) for JellyTau.
|
||||
#
|
||||
# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
|
||||
# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
|
||||
# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
|
||||
# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-desktop-linux.sh # deb + rpm
|
||||
# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
|
||||
# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUNDLES="${BUNDLES:-deb,rpm}"
|
||||
|
||||
echo "🐧 Building JellyTau Linux desktop packages"
|
||||
echo "==========================================="
|
||||
echo "Bundles: $BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
||||
# exactly which Linux formats are produced (never NSIS here).
|
||||
bun run tauri build --bundles "$BUNDLES"
|
||||
|
||||
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
||||
echo ""
|
||||
echo "✅ Built packages:"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
echo ""
|
||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
|
||||
#
|
||||
# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
|
||||
# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
|
||||
# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
|
||||
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
|
||||
# it can bundle the NSIS installer from a Linux host.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
# - cargo-xwin (cargo install --locked cargo-xwin)
|
||||
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
||||
# - nsis (makensis) (installer generator)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-windows-cross.sh # exe + NSIS installer
|
||||
# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
|
||||
# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="x86_64-pc-windows-msvc"
|
||||
WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
|
||||
|
||||
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
|
||||
echo "================================================================"
|
||||
echo "Video plays via WebView2; audio via the webview <audio> backend."
|
||||
echo "Bundles: $WIN_BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
|
||||
# a real Windows build and enable the nsis/msi bundlers on a Linux host.
|
||||
#
|
||||
# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
|
||||
# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
|
||||
# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
|
||||
# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
|
||||
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
||||
# that CLI validation — the bundler then picks nsis once it knows the target is
|
||||
# Windows.
|
||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||
else
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET"
|
||||
fi
|
||||
|
||||
BIN_DIR="src-tauri/target/$TARGET/release"
|
||||
echo ""
|
||||
echo "✅ Built Windows artifacts:"
|
||||
find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
|
||||
echo ""
|
||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
||||
fi
|
||||
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,15 @@ if [ -d "$RES_SRC" ]; then
|
||||
cp "$dir"/* "$RES_DST/$name/"
|
||||
echo " Copied res: $name"
|
||||
done
|
||||
|
||||
# values/ (themes.xml): status-bar styling that `tauri android init` does
|
||||
# not generate. Previously this directory was tracked but never copied, so
|
||||
# the theme customizations below never reached a build.
|
||||
if [ -d "$RES_SRC/values" ]; then
|
||||
mkdir -p "$RES_DST/values"
|
||||
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||
echo " Copied res: values"
|
||||
fi
|
||||
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+26
-3
@@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
override fun onSeekTo(position: Long) {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||
// The scrubber is absolute; Rust owns the seek in absolute terms
|
||||
// (in a background-audio handoff it rebuilds the stream at this
|
||||
// StartTimeTicks). Send the absolute position as-is.
|
||||
val positionSeconds = position / 1000.0
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
@@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the MediaSession metadata and playback state, plus the notification.
|
||||
*
|
||||
@@ -292,8 +314,8 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
|
||||
session.setMetadata(metadataBuilder.build())
|
||||
|
||||
// Update MediaSession playback state
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
// Update MediaSession playback state (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
@@ -322,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
|
||||
+61
-16
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
use crate::connectivity::ConnectivityMonitor;
|
||||
use crate::jellyfin::http_client::HttpClient;
|
||||
|
||||
pub use session_verifier::SessionVerifier;
|
||||
|
||||
@@ -99,7 +99,10 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Set the connectivity monitor (for marking server reachability)
|
||||
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||
pub fn set_connectivity_monitor(
|
||||
&mut self,
|
||||
monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>,
|
||||
) {
|
||||
self.connectivity_monitor = Some(monitor);
|
||||
}
|
||||
|
||||
@@ -133,9 +136,17 @@ impl AuthManager {
|
||||
|
||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||
|
||||
match self.http_client.get_json_fast::<PublicSystemInfo>(&endpoint).await {
|
||||
match self
|
||||
.http_client
|
||||
.get_json_fast::<PublicSystemInfo>(&endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||
log::info!(
|
||||
"[AuthManager] Connected to server: {} ({})",
|
||||
info.server_name,
|
||||
info.version
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.json(&serde_json::json!({
|
||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||
let auth_response: AuthenticateByNameResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||
log::info!(
|
||||
"[AuthManager] Login successful for user: {} ({})",
|
||||
auth_response.user.name,
|
||||
auth_response.user.id
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -243,13 +269,19 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request manually for custom headers
|
||||
let request = self.http_client.client.get(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.get(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
// Use retry logic
|
||||
let response = self.http_client.request_with_retry(request).await
|
||||
let response = self
|
||||
.http_client
|
||||
.request_with_retry(request)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||
format!("Session verification failed: {}", e)
|
||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Mark server as unreachable for auth errors
|
||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
let monitor = monitor.lock().await;
|
||||
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||
monitor
|
||||
.mark_unreachable(Some(format!("Authentication failed: {}", status)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
let user_response: JellyfinUser = response.json().await
|
||||
let user_response: JellyfinUser = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||
|
||||
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||
log::info!(
|
||||
"[AuthManager] Session verified successfully for: {}",
|
||||
user_response.name
|
||||
);
|
||||
|
||||
// Mark server as reachable
|
||||
if let Some(monitor) = &self.connectivity_monitor {
|
||||
@@ -306,7 +348,10 @@ impl AuthManager {
|
||||
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||
|
||||
// Build request
|
||||
let request = self.http_client.client.post(&endpoint)
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.post(&endpoint)
|
||||
.header("X-Emby-Authorization", auth_header)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{AuthManager, User};
|
||||
|
||||
@@ -65,7 +65,10 @@ impl SessionVerifier {
|
||||
let session = auth_manager.get_session().await;
|
||||
|
||||
if let Some(session) = session {
|
||||
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
||||
log::debug!(
|
||||
"[SessionVerifier] Verifying session for: {}",
|
||||
session.username
|
||||
);
|
||||
|
||||
// Verify the session
|
||||
match auth_manager
|
||||
@@ -113,7 +116,10 @@ impl SessionVerifier {
|
||||
reason: "Session expired".to_string(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,12 +137,18 @@ impl SessionVerifier {
|
||||
message: e.clone(),
|
||||
};
|
||||
if let Err(e) = app.emit("auth:network-error", event) {
|
||||
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Failed to emit event: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown error - log but don't invalidate
|
||||
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
|
||||
log::error!(
|
||||
"[SessionVerifier] Unknown error during verification: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Authentication and session-lifecycle commands.
|
||||
//!
|
||||
//! TRACES: UR-042 | IR-009, IR-014, JA-002 | DR-054
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||
use crate::auth::{AuthManager, AuthResult, ServerInfo, Session, SessionVerifier};
|
||||
|
||||
/// Wrapper for AuthManager to manage in Tauri state
|
||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||
@@ -27,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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Tauri commands for the offline "browse & queue" feature.
|
||||
//!
|
||||
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
|
||||
//!
|
||||
//! Two backend pieces support browsing the full server catalog while offline
|
||||
//! and queueing downloads that fire on reconnect:
|
||||
//!
|
||||
@@ -17,8 +19,8 @@ use std::sync::Arc;
|
||||
use log::{info, warn};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||
use crate::commands::repository::RepositoryManagerWrapper;
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::repository::types::GetItemsOptions;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
@@ -79,7 +81,10 @@ pub async fn sync_full_catalog(
|
||||
};
|
||||
|
||||
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
|
||||
info!(
|
||||
"[Catalog] Full sync starting across {} libraries",
|
||||
libraries.len()
|
||||
);
|
||||
|
||||
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
@@ -104,7 +109,10 @@ pub async fn sync_full_catalog(
|
||||
items_cached += items.len();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to sync library '{}': {:?}",
|
||||
library.name, e
|
||||
);
|
||||
libraries_failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -208,10 +216,16 @@ where
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
|
||||
return Ok(ResumeQueuedResult {
|
||||
resolved: 0,
|
||||
failed: 0,
|
||||
});
|
||||
}
|
||||
|
||||
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
|
||||
info!(
|
||||
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||
rows.len()
|
||||
);
|
||||
|
||||
let mut resolved = 0usize;
|
||||
let mut failed = 0usize;
|
||||
@@ -240,7 +254,10 @@ where
|
||||
Ok(n) if n > 0 => resolved += 1,
|
||||
Ok(_) => {} // already resolved by someone else; not a failure
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to persist URL for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -309,17 +326,22 @@ pub async fn resume_queued_downloads(
|
||||
let repo = Arc::clone(&repo_for_resolve);
|
||||
async move {
|
||||
if media_type == "video" {
|
||||
Some(<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
))
|
||||
Some(
|
||||
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||
repo.as_ref(),
|
||||
&item_id,
|
||||
&quality,
|
||||
None,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
match repo.get_audio_stream_url(&item_id).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e);
|
||||
warn!(
|
||||
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||
item_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -341,7 +363,10 @@ pub async fn resume_queued_downloads(
|
||||
pump_download_queue(app, db_service, active_downloads).await;
|
||||
}
|
||||
|
||||
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
|
||||
info!(
|
||||
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||
resolved, failed
|
||||
);
|
||||
|
||||
Ok(ResumeQueuedResult { resolved, failed })
|
||||
}
|
||||
@@ -384,14 +409,21 @@ mod tests {
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::String(status.to_string()),
|
||||
stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
stream_url
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
media_type
|
||||
.map(|s| QueryParam::String(s.to_string()))
|
||||
.unwrap_or(QueryParam::Null),
|
||||
],
|
||||
);
|
||||
db.execute(q).await.unwrap();
|
||||
}
|
||||
|
||||
async fn get_row(db: &Arc<RusqliteService>, item_id: &str) -> (String, Option<String>, Option<String>) {
|
||||
async fn get_row(
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
) -> (String, Option<String>, Option<String>) {
|
||||
let q = Query::with_params(
|
||||
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||
vec![QueryParam::String(item_id.to_string())],
|
||||
@@ -401,6 +433,12 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// IT-017: a download queued from a greyed-out offline catalog entry
|
||||
/// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
|
||||
/// resolved and the row is healed (URL + target dir) so the pump can start
|
||||
/// it — while already-resolved rows are left untouched.
|
||||
///
|
||||
/// TRACES: UR-052, UR-011 | IT-017
|
||||
#[tokio::test]
|
||||
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||
let db = test_db();
|
||||
@@ -411,11 +449,12 @@ mod tests {
|
||||
// A completed row: irrelevant.
|
||||
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||
Some(format!("http://resolved/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
assert_eq!(out.failed, 0);
|
||||
@@ -455,12 +494,13 @@ mod tests {
|
||||
let db = test_db();
|
||||
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||
|
||||
let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let out =
|
||||
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||
assert_eq!(media_type, "video");
|
||||
Some(format!("http://transcode/{item_id}"))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.resolved, 1);
|
||||
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||
|
||||
@@ -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,7 +1522,11 @@ fn spawn_download_worker(
|
||||
// Free the slot before pumping so the next download can take it.
|
||||
if let Ok(mut active) = active_downloads.lock() {
|
||||
active.remove(&download_id);
|
||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
||||
debug!(
|
||||
" Unregistered download {}. Active downloads: {}",
|
||||
download_id,
|
||||
active.len()
|
||||
);
|
||||
}
|
||||
|
||||
// The pump runs downloads in the background, so the terminal status MUST
|
||||
@@ -1281,7 +1542,10 @@ fn spawn_download_worker(
|
||||
let database = match db.0.lock() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to lock database after download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -1290,7 +1554,10 @@ fn spawn_download_worker(
|
||||
|
||||
match result {
|
||||
Ok(res) => {
|
||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||
info!(
|
||||
"Download completed successfully: {} bytes",
|
||||
res.bytes_downloaded
|
||||
);
|
||||
let file_path = target_path.to_string_lossy().to_string();
|
||||
|
||||
let update = Query::with_params(
|
||||
@@ -1305,7 +1572,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(e) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
|
||||
error!(
|
||||
"[pump] Failed to persist completed status for download {}: {}",
|
||||
download_id, e
|
||||
);
|
||||
}
|
||||
|
||||
let completed_event = DownloadEvent::Completed {
|
||||
@@ -1329,7 +1599,10 @@ fn spawn_download_worker(
|
||||
],
|
||||
);
|
||||
if let Err(db_err) = db_service.execute(update).await {
|
||||
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
|
||||
error!(
|
||||
"[pump] Failed to persist failed status for download {}: {}",
|
||||
download_id, db_err
|
||||
);
|
||||
}
|
||||
|
||||
let failed_event = DownloadEvent::Failed {
|
||||
@@ -1352,7 +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())
|
||||
@@ -1376,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 {
|
||||
@@ -1413,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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1500,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())
|
||||
@@ -1598,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
|
||||
@@ -1626,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 {
|
||||
@@ -1665,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 {
|
||||
@@ -1834,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]
|
||||
@@ -2069,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};
|
||||
|
||||
@@ -25,11 +25,11 @@ pub use device::*;
|
||||
pub use download::*;
|
||||
pub use offline::*;
|
||||
pub use playback_mode::*;
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||
pub use playback_reporting::*;
|
||||
pub use player::*;
|
||||
pub use playlist::*;
|
||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||
pub use sessions::*;
|
||||
pub use storage::*;
|
||||
pub use sync::*;
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -12,13 +14,32 @@ pub async fn player_set_audio_settings(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
settings: AudioSettings,
|
||||
) -> Result<AudioSettings, String> {
|
||||
// Validate/normalise domain values before applying: clamp crossfade to its
|
||||
// range and normalise the equalizer band vector (length + gain clamps).
|
||||
let validated = settings
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
let mut controller = player.0.lock().await;
|
||||
controller
|
||||
.set_audio_settings(&settings)
|
||||
.set_audio_settings(&validated)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(controller.audio_settings())
|
||||
}
|
||||
|
||||
/// The built-in equalizer presets and their per-band gain curves (dB), for the
|
||||
/// settings UI. The curve numbers are domain data defined by the band layout,
|
||||
/// so the frontend reads them here rather than encoding them.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
|
||||
Ok(EqPreset::ALL
|
||||
.iter()
|
||||
.map(|p| (*p, p.gains().to_vec()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_audio_settings(
|
||||
|
||||
@@ -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,31 +189,6 @@ impl HttpClient {
|
||||
Err(last_error.unwrap())
|
||||
}
|
||||
|
||||
/// Make a GET request with retry
|
||||
pub async fn get_with_retry(&self, url: &str) -> Result<Response, reqwest::Error> {
|
||||
let request = self.client.get(url).build()?;
|
||||
self.request_with_retry(request).await
|
||||
}
|
||||
|
||||
/// Make a GET request and deserialize JSON response with retry
|
||||
pub async fn get_json_with_retry<T: DeserializeOwned>(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> Result<T, String> {
|
||||
let response = self.get_with_retry(url).await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(format!("HTTP {}: {}", status, error_text));
|
||||
}
|
||||
|
||||
response.json::<T>().await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))
|
||||
}
|
||||
|
||||
/// Make a GET request and deserialize JSON with a short timeout and no retries.
|
||||
///
|
||||
/// Intended for the initial "connect to server" probe on the login screen:
|
||||
@@ -258,17 +230,17 @@ impl HttpClient {
|
||||
|
||||
/// Quick ping to check if a server is reachable (no retry)
|
||||
pub async fn ping(&self, url: &str) -> bool {
|
||||
let request = self.client.get(url)
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(5)) // Shorter timeout for ping
|
||||
.build();
|
||||
|
||||
match request {
|
||||
Ok(req) => {
|
||||
match self.client.execute(req).await {
|
||||
Ok(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,
|
||||
|
||||
+357
-120
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod playback_mode;
|
||||
@@ -14,120 +15,292 @@ mod storage;
|
||||
mod thumbnail;
|
||||
pub mod utils;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_specta::Builder;
|
||||
use log::{error, info};
|
||||
#[cfg(target_os = "android")]
|
||||
use log::warn;
|
||||
use log::{error, info};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_specta::Builder;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
use commands::{
|
||||
sync_full_catalog, catalog_sync_status, set_show_server_catalog, resume_queued_downloads,
|
||||
cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download,
|
||||
download_album, download_item, download_item_and_start, download_video, download_series, download_season,
|
||||
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
||||
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
|
||||
pin_item, unpin_item, is_item_pinned,
|
||||
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
||||
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
||||
player_get_video_settings, player_next, player_pause, player_play, player_play_album_track,
|
||||
player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track,
|
||||
player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle,
|
||||
player_toggle_shuffle,
|
||||
// Sleep timer and autoplay commands
|
||||
player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer,
|
||||
player_get_autoplay_settings, player_set_autoplay_settings,
|
||||
player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended,
|
||||
// HTML5 video state-report commands
|
||||
player_report_state, player_report_position, player_report_media_loaded,
|
||||
// Queue manipulation commands
|
||||
player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids,
|
||||
player_remove_from_queue, player_move_in_queue, player_skip_to,
|
||||
// Preload commands
|
||||
player_preload_upcoming, player_set_cache_config, player_get_cache_config,
|
||||
// Jellyfin reporting commands
|
||||
player_configure_jellyfin, player_disable_jellyfin,
|
||||
// Session management commands
|
||||
player_get_session, player_dismiss_session,
|
||||
// Remote session control commands
|
||||
remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume,
|
||||
remote_session_toggle_mute,
|
||||
// LMS multi-room sync group commands
|
||||
lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group,
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper,
|
||||
// Playback mode commands
|
||||
playback_mode_get_current, playback_mode_set, playback_mode_is_transferring,
|
||||
playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring,
|
||||
playback_mode_get_remote_status,
|
||||
// Playback reporting commands
|
||||
playback_reporter_init, playback_reporter_destroy,
|
||||
playback_report_start, playback_report_progress, playback_report_stopped,
|
||||
playback_mark_played, PlaybackReporterWrapper,
|
||||
// Auth commands
|
||||
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
|
||||
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
|
||||
auth_stop_verification, auth_reauthenticate,
|
||||
// Device commands
|
||||
device_get_id, device_set_id,
|
||||
// Connectivity commands
|
||||
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
||||
connectivity_start_monitoring, connectivity_stop_monitoring,
|
||||
connectivity_mark_reachable, connectivity_mark_unreachable,
|
||||
// Storage commands
|
||||
storage_delete_server, storage_delete_user, storage_get_access_token,
|
||||
storage_get_active_session, storage_get_active_user, storage_get_path,
|
||||
storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size,
|
||||
storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server,
|
||||
storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress,
|
||||
storage_update_playback_context,
|
||||
// Offline cache commands
|
||||
storage_get_libraries, storage_get_items, storage_get_item, storage_search_items,
|
||||
storage_save_library, storage_save_item, storage_get_pending_sync_count,
|
||||
// Sync queue commands
|
||||
sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed,
|
||||
sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user,
|
||||
// Thumbnail cache and image commands
|
||||
thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit,
|
||||
thumbnail_clear_cache, thumbnail_delete_item, image_get_url,
|
||||
// People cache commands
|
||||
storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people,
|
||||
// Series audio preferences
|
||||
storage_save_series_audio_preference, storage_get_series_audio_preference,
|
||||
// Repository commands
|
||||
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
||||
repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items,
|
||||
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
||||
repository_get_rediscover_albums,
|
||||
repository_get_genres, repository_search, repository_get_playback_info,
|
||||
repository_get_video_stream_url, repository_get_audio_stream_url,
|
||||
repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream,
|
||||
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
|
||||
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
|
||||
repository_get_person, repository_get_items_by_person, repository_get_similar_items,
|
||||
repository_get_subtitle_url, repository_get_video_download_url,
|
||||
// Playlist commands
|
||||
playlist_create, playlist_delete, playlist_rename, playlist_get_items,
|
||||
playlist_add_items, playlist_remove_items, playlist_move_item,
|
||||
// Conversion commands
|
||||
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
||||
calc_progress, convert_percent_to_volume,
|
||||
AuthManagerWrapper, SessionVerifierWrapper,
|
||||
ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper,
|
||||
MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper,
|
||||
PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper,
|
||||
};
|
||||
#[cfg(target_os = "android")]
|
||||
use playback_mode::PlaybackModeManager;
|
||||
use auth::AuthManager;
|
||||
use commands::{
|
||||
auth_connect_to_server,
|
||||
auth_get_session,
|
||||
// Auth commands
|
||||
auth_initialize,
|
||||
auth_login,
|
||||
auth_logout,
|
||||
auth_reauthenticate,
|
||||
auth_set_session,
|
||||
auth_start_verification,
|
||||
auth_stop_verification,
|
||||
auth_verify_session,
|
||||
calc_progress,
|
||||
cancel_download,
|
||||
catalog_sync_status,
|
||||
clear_stale_downloads,
|
||||
// Connectivity commands
|
||||
connectivity_check_server,
|
||||
connectivity_get_status,
|
||||
connectivity_mark_reachable,
|
||||
connectivity_mark_unreachable,
|
||||
connectivity_set_server_url,
|
||||
connectivity_start_monitoring,
|
||||
connectivity_stop_monitoring,
|
||||
convert_percent_to_volume,
|
||||
convert_ticks_to_seconds,
|
||||
delete_album_downloads,
|
||||
delete_all_downloads,
|
||||
delete_download,
|
||||
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_eq_presets,
|
||||
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
|
||||
@@ -138,7 +311,7 @@ use player::NullBackend;
|
||||
use player::MpvBackend;
|
||||
use settings::VideoSettings;
|
||||
use storage::Database;
|
||||
use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig};
|
||||
use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use credentials::initialize_secure_storage;
|
||||
@@ -147,7 +320,9 @@ use credentials::initialize_secure_storage;
|
||||
use player::ExoPlayerBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler};
|
||||
use player::{
|
||||
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
||||
};
|
||||
|
||||
/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
|
||||
///
|
||||
@@ -210,7 +385,11 @@ impl MediaSessionHandler {
|
||||
"play" => client.send_session_command(session_id, "Unpause").await,
|
||||
"pause" => client.send_session_command(session_id, "Pause").await,
|
||||
"next" => client.send_session_command(session_id, "NextTrack").await,
|
||||
"previous" => client.send_session_command(session_id, "PreviousTrack").await,
|
||||
"previous" => {
|
||||
client
|
||||
.send_session_command(session_id, "PreviousTrack")
|
||||
.await
|
||||
}
|
||||
cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
|
||||
Ok(seconds) => {
|
||||
let ticks = (seconds * 10_000_000.0) as i64;
|
||||
@@ -297,7 +476,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),
|
||||
}
|
||||
@@ -334,6 +516,12 @@ fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str
|
||||
}
|
||||
|
||||
/// Create the appropriate player backend for the current platform.
|
||||
// playback_reporter/position_throttler are consumed only by the native audio
|
||||
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "linux", target_os = "android")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
fn create_player_backend(
|
||||
app_handle: tauri::AppHandle,
|
||||
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||
@@ -355,9 +543,16 @@ fn create_player_backend(
|
||||
Ok(java_vm) => {
|
||||
match java_vm.attach_current_thread() {
|
||||
Ok(mut env) => {
|
||||
let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||
let context_obj =
|
||||
unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
|
||||
|
||||
match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) {
|
||||
match ExoPlayerBackend::new(
|
||||
&mut env,
|
||||
&context_obj,
|
||||
_event_emitter.clone(),
|
||||
playback_reporter.clone(),
|
||||
position_throttler.clone(),
|
||||
) {
|
||||
Ok(backend) => {
|
||||
info!("Successfully initialized ExoPlayer backend for Android");
|
||||
return Box::new(backend);
|
||||
@@ -370,13 +565,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());
|
||||
}
|
||||
}
|
||||
@@ -419,11 +622,19 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other platforms
|
||||
// Platforms with no native audio backend (e.g. Windows): render audio-only
|
||||
// playback through a webview <audio> element (all video already renders in
|
||||
// the webview). Falls back to NullBackend only if the backend can't init.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
warn!("WARNING: No audio backend available for this platform");
|
||||
Box::new(NullBackend::new())
|
||||
info!("No native audio backend for this platform - using webview <audio> backend");
|
||||
match player::WebviewAudioBackend::new(_event_emitter) {
|
||||
Ok(backend) => Box::new(backend),
|
||||
Err(e) => {
|
||||
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
|
||||
Box::new(NullBackend::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +651,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,
|
||||
@@ -468,6 +681,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_skip_to,
|
||||
player_set_audio_settings,
|
||||
player_get_audio_settings,
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
// Sleep timer and autoplay commands
|
||||
@@ -579,6 +793,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,
|
||||
@@ -595,6 +810,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
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
|
||||
@@ -644,6 +862,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,
|
||||
@@ -656,6 +877,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_playback_info,
|
||||
repository_get_video_stream_url,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_live_tv_channels,
|
||||
repository_get_channels,
|
||||
repository_open_live_stream,
|
||||
@@ -722,7 +944,12 @@ fn enable_linux_hardware_video_decoding() {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_available_vaapi_decoders() {
|
||||
const HW_DECODERS: &[&str] = &[
|
||||
"vah264dec", "vah265dec", "vavp9dec", "vaav1dec", "vampeg2dec", "vavp8dec",
|
||||
"vah264dec",
|
||||
"vah265dec",
|
||||
"vavp9dec",
|
||||
"vaav1dec",
|
||||
"vampeg2dec",
|
||||
"vavp8dec",
|
||||
];
|
||||
|
||||
let available: Vec<&str> = HW_DECODERS
|
||||
@@ -969,6 +1196,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") {
|
||||
@@ -996,6 +1226,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();
|
||||
@@ -1055,7 +1292,6 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod specta_bindings {
|
||||
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
|
||||
@@ -1063,9 +1299,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@ impl PlaybackModeManager {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||
log::warn!("[PlaybackMode] Failed to enable remote volume/service: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||
e
|
||||
);
|
||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||
}
|
||||
}
|
||||
@@ -154,8 +157,16 @@ impl PlaybackModeManager {
|
||||
/// Send volume command to remote session
|
||||
/// Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||
#[allow(dead_code)] // Called from Android JNI callback
|
||||
pub async fn send_remote_volume_command(&self, command: &str, volume: i32) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}", command, volume);
|
||||
pub async fn send_remote_volume_command(
|
||||
&self,
|
||||
command: &str,
|
||||
volume: i32,
|
||||
) -> Result<(), String> {
|
||||
log::info!(
|
||||
"[PlaybackMode] send_remote_volume_command ENTERED: command={}, volume={}",
|
||||
command,
|
||||
volume
|
||||
);
|
||||
|
||||
// Get the current session ID
|
||||
let session_id = match self.get_mode() {
|
||||
@@ -166,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");
|
||||
|
||||
@@ -196,7 +207,12 @@ impl PlaybackModeManager {
|
||||
log::info!("[PlaybackMode] About to call client.session_set_volume...");
|
||||
|
||||
// Send the volume command
|
||||
log::info!("[PlaybackMode] Sending {} command to session {} (volume: {})", command, session_id, volume);
|
||||
log::info!(
|
||||
"[PlaybackMode] Sending {} command to session {} (volume: {})",
|
||||
command,
|
||||
session_id,
|
||||
volume
|
||||
);
|
||||
let result = client.session_set_volume(session_id, volume).await;
|
||||
|
||||
match &result {
|
||||
@@ -209,8 +225,11 @@ impl PlaybackModeManager {
|
||||
|
||||
/// Extract Jellyfin item IDs from queue items
|
||||
/// Returns (item_ids, adjusted_current_index)
|
||||
fn extract_jellyfin_ids(&self, items: &[crate::player::MediaItem], original_index: usize) -> Result<(Vec<String>, usize), String> {
|
||||
|
||||
fn extract_jellyfin_ids(
|
||||
&self,
|
||||
items: &[crate::player::MediaItem],
|
||||
original_index: usize,
|
||||
) -> Result<(Vec<String>, usize), String> {
|
||||
let mut jellyfin_ids: Vec<String> = Vec::new();
|
||||
let mut adjusted_index: Option<usize> = None;
|
||||
let mut jellyfin_item_count = 0;
|
||||
@@ -236,7 +255,9 @@ impl PlaybackModeManager {
|
||||
"[PlaybackMode] Currently playing item (index {}) does not have a Jellyfin ID",
|
||||
original_index
|
||||
);
|
||||
return Err("Cannot transfer: currently playing item is not from Jellyfin".to_string());
|
||||
return Err(
|
||||
"Cannot transfer: currently playing item is not from Jellyfin".to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,7 +290,9 @@ impl PlaybackModeManager {
|
||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||
|
||||
// Perform the transfer
|
||||
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
|
||||
let result = self
|
||||
.transfer_to_remote_inner(&session_id, position_override)
|
||||
.await;
|
||||
|
||||
// Clear transferring flag
|
||||
self.is_transferring.store(false, Ordering::Relaxed);
|
||||
@@ -283,7 +306,10 @@ impl PlaybackModeManager {
|
||||
position_override: Option<f64>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
||||
debug!(
|
||||
"[PlaybackMode] transfer_to_remote_inner: session_id={}",
|
||||
session_id
|
||||
);
|
||||
|
||||
// If we're already controlling a remote session, that *old* session — not
|
||||
// the idle local player — is the source of truth for the current track and
|
||||
@@ -307,13 +333,26 @@ impl PlaybackModeManager {
|
||||
let original_index = queue.current_index().unwrap_or(0);
|
||||
let items = queue.items();
|
||||
|
||||
log::info!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
debug!("[PlaybackMode] Queue has {} items, original_index={}", items.len(), original_index);
|
||||
log::info!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
debug!(
|
||||
"[PlaybackMode] Queue has {} items, original_index={}",
|
||||
items.len(),
|
||||
original_index
|
||||
);
|
||||
|
||||
// Log each item's jellyfin_id for debugging
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
let jf_id = item.jellyfin_id().unwrap_or("NONE");
|
||||
log::debug!("[PlaybackMode] Item {}: id={}, jellyfin_id={}", i, item.id, jf_id);
|
||||
log::debug!(
|
||||
"[PlaybackMode] Item {}: id={}, jellyfin_id={}",
|
||||
i,
|
||||
item.id,
|
||||
jf_id
|
||||
);
|
||||
}
|
||||
|
||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||
@@ -372,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) => {
|
||||
@@ -405,7 +441,9 @@ impl PlaybackModeManager {
|
||||
match client.get_session(prev_session_id).await {
|
||||
Ok(Some(session)) => {
|
||||
// Resume at the previous session's position.
|
||||
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
|
||||
if let Some(ticks) =
|
||||
session.play_state.as_ref().and_then(|ps| ps.position_ticks)
|
||||
{
|
||||
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
||||
log::info!(
|
||||
"[PlaybackMode] Using previous remote position: {:.2}s",
|
||||
@@ -413,7 +451,11 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
// Resume on whichever track the previous session reached.
|
||||
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
|
||||
if let Some(now_id) = session
|
||||
.now_playing_item
|
||||
.as_ref()
|
||||
.and_then(|i| i.id.as_deref())
|
||||
{
|
||||
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
||||
log::info!(
|
||||
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
||||
@@ -430,8 +472,13 @@ impl PlaybackModeManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
|
||||
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
|
||||
Ok(None) => log::warn!(
|
||||
"[PlaybackMode] Previous remote session not found while reading state"
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"[PlaybackMode] Failed to read previous remote session: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +487,10 @@ impl PlaybackModeManager {
|
||||
|
||||
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||
match &queue_context {
|
||||
QueueContext::Album { album_id, album_name } => {
|
||||
QueueContext::Album {
|
||||
album_id,
|
||||
album_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring album '{}' (ID: {}) with {} tracks to remote",
|
||||
album_name,
|
||||
@@ -448,7 +498,10 @@ impl PlaybackModeManager {
|
||||
queue_ids.len()
|
||||
);
|
||||
}
|
||||
QueueContext::Playlist { playlist_id, playlist_name } => {
|
||||
QueueContext::Playlist {
|
||||
playlist_id,
|
||||
playlist_name,
|
||||
} => {
|
||||
log::info!(
|
||||
"[PlaybackMode] Transferring playlist '{}' (ID: {}) with {} tracks to remote",
|
||||
playlist_name,
|
||||
@@ -540,7 +593,11 @@ impl PlaybackModeManager {
|
||||
return Err("Remote session not found".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Error polling session (attempt {}): {}", attempts, e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Error polling session (attempt {}): {}",
|
||||
attempts,
|
||||
e
|
||||
);
|
||||
// Continue polling - transient errors are OK
|
||||
}
|
||||
}
|
||||
@@ -572,9 +629,15 @@ impl PlaybackModeManager {
|
||||
// up with two devices playing at once. Do this only after the new session
|
||||
// is confirmed playing, so a failure here doesn't leave us with silence.
|
||||
if let Some(prev_session_id) = previous_remote_session {
|
||||
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping previous remote session {}",
|
||||
prev_session_id
|
||||
);
|
||||
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
||||
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop previous remote session: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +657,9 @@ impl PlaybackModeManager {
|
||||
);
|
||||
}
|
||||
|
||||
player.stop().map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
player
|
||||
.stop()
|
||||
.map_err(|e| format!("Failed to stop playback: {}", e))?;
|
||||
|
||||
// Log queue state AFTER stop (should be unchanged)
|
||||
{
|
||||
@@ -677,11 +742,20 @@ impl PlaybackModeManager {
|
||||
};
|
||||
|
||||
// Stop remote playback
|
||||
log::info!("[PlaybackMode] Stopping remote playback on session: {}", session_id);
|
||||
match client.send_session_command(session_id.clone(), "Stop").await {
|
||||
log::info!(
|
||||
"[PlaybackMode] Stopping remote playback on session: {}",
|
||||
session_id
|
||||
);
|
||||
match client
|
||||
.send_session_command(session_id.clone(), "Stop")
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("[PlaybackMode] Stop command sent successfully"),
|
||||
Err(e) => {
|
||||
log::warn!("[PlaybackMode] Failed to stop remote session (non-fatal): {}", e);
|
||||
log::warn!(
|
||||
"[PlaybackMode] Failed to stop remote session (non-fatal): {}",
|
||||
e
|
||||
);
|
||||
// Don't fail the transfer if we can't stop the remote session
|
||||
// The user is already playing locally, so this is not critical
|
||||
}
|
||||
@@ -843,7 +917,10 @@ mod tests {
|
||||
fn test_start_position_ticks_from_seconds() {
|
||||
// Mid-track positions convert to ticks (10M ticks per second).
|
||||
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
||||
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
|
||||
assert_eq!(
|
||||
start_position_ticks_from_seconds(123.45),
|
||||
Some(1_234_500_000)
|
||||
);
|
||||
|
||||
// At/near the start, send no resume position so the track casts from 0.
|
||||
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
||||
@@ -871,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),
|
||||
@@ -902,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),
|
||||
@@ -931,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(
|
||||
@@ -965,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"),
|
||||
];
|
||||
@@ -1000,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);
|
||||
|
||||
@@ -817,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
if auto_advance {
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
// Background audio-only episode: the frontend that normally
|
||||
// performs the advance (goto /player/<id>) is suspended, so
|
||||
// the backend must load the next episode's audio-only stream
|
||||
// itself — otherwise playback just stops at the boundary.
|
||||
let is_bg_audio_episode =
|
||||
controller.lock().await.current_is_audio_episode();
|
||||
if is_bg_audio_episode {
|
||||
log::info!(
|
||||
"[Autoplay] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"[Autoplay] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else {
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
} else {
|
||||
// Foreground: frontend drives the advance off the countdown.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1002,7 +1071,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 +1106,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 +1121,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 +1146,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 +1215,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 +1343,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,
|
||||
@@ -322,6 +332,7 @@ mod tests {
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
@@ -370,6 +381,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 +440,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 +493,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),
|
||||
|
||||
@@ -156,6 +156,25 @@ pub enum PlayerStatusEvent {
|
||||
/// Target position in seconds (only meaningful for "seek").
|
||||
position: Option<f64>,
|
||||
},
|
||||
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
///
|
||||
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
/// element in the webview, mirroring how all video already renders through
|
||||
/// the webview `<video>`. The element then reports its state/position back
|
||||
/// through the `player_report_*` commands, so the Rust controller stays the
|
||||
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
/// via `ControlCommand`.
|
||||
WebviewAudioLoad {
|
||||
/// Stream URL for the `<audio>` element to play.
|
||||
url: String,
|
||||
/// Jellyfin item id, used as the media_id when reporting state back.
|
||||
media_id: Option<String>,
|
||||
/// Resume position in seconds (0 = start from the beginning).
|
||||
position: f64,
|
||||
/// Whether to begin playing immediately after loading.
|
||||
autoplay: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
|
||||
@@ -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),
|
||||
|
||||
+623
-98
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