Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
7b8a8f66e5 | ||
|
|
2e479d05b3 | ||
|
|
1992a8187d | ||
|
|
532ffa661a | ||
|
|
2a1f1689b4 | ||
|
|
a2cd9978f0 | ||
|
|
36be192d44 | ||
|
|
acb7e5f221 | ||
|
|
68c8602230 | ||
|
|
2d141e5bf4 | ||
|
|
c58cc0cf46 | ||
|
|
8938e3fdba | ||
|
|
e2c12615c5 | ||
|
|
0b5a3aa176 | ||
|
|
37455bc470 | ||
|
|
a64e1b1fb4 |
@@ -49,6 +49,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bun install
|
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
|
- name: Run frontend tests
|
||||||
run: |
|
run: |
|
||||||
bunx svelte-kit sync
|
bunx svelte-kit sync
|
||||||
@@ -60,16 +67,24 @@ jobs:
|
|||||||
cargo test
|
cargo test
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
build:
|
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||||
name: Build Android APK
|
# the full signed release APK is built only on tag pushes by build-release.yml
|
||||||
|
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
||||||
|
# too would duplicate a ~15min build and, without the sync step, produced an
|
||||||
|
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
||||||
|
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
||||||
|
# breakage without linking, bundling, or signing.
|
||||||
|
android-check:
|
||||||
|
name: Android Compile Check
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
NDK_VERSION: 27.0.11902837
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -97,42 +112,13 @@ jobs:
|
|||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: bun install
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Cargo check (aarch64-linux-android)
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Ensure Android NDK
|
|
||||||
run: |
|
|
||||||
if [ ! -d "$NDK_HOME" ]; then
|
|
||||||
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
|
|
||||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
|
|
||||||
fi
|
|
||||||
echo "Using NDK at $NDK_HOME"
|
|
||||||
ls "$NDK_HOME"
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: |
|
run: |
|
||||||
|
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
||||||
|
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
||||||
|
export AR_aarch64_linux_android="$TC/llvm-ar"
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
echo "" | bunx tauri android init
|
cargo check --target aarch64-linux-android --lib
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Android APK
|
|
||||||
id: build
|
|
||||||
run: |
|
|
||||||
mkdir -p artifacts
|
|
||||||
bun run tauri android build --apk true --target aarch64
|
|
||||||
|
|
||||||
# Find the generated APK file
|
|
||||||
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-apk
|
|
||||||
path: ${{ steps.build.outputs.artifact }}
|
|
||||||
retention-days: 30
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
@@ -121,6 +121,64 @@ jobs:
|
|||||||
path: dist/linux/
|
path: dist/linux/
|
||||||
retention-days: 30
|
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:
|
build-android:
|
||||||
name: Build Android
|
name: Build Android
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
@@ -161,10 +219,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: |
|
||||||
REF="${GITHUB_REF#refs/tags/v}"
|
# On a tag build, the tag is the single source of truth for the
|
||||||
VERSION="${REF#refs/heads/}"
|
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||||
# On non-tag runs keep whatever is in tauri.conf.json
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
echo "Setting version to $VERSION"
|
echo "Setting version to $VERSION"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||||
fi
|
fi
|
||||||
@@ -173,6 +231,35 @@ jobs:
|
|||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
- name: Pin a monotonic Android versionCode
|
||||||
|
run: |
|
||||||
|
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||||
|
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||||
|
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||||
|
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||||
|
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||||
|
#
|
||||||
|
# Derive an explicit code that is both monotonic in semver order and
|
||||||
|
# always above the 1000 floor already in the field:
|
||||||
|
# code = 1000 + major*10000 + minor*100 + patch
|
||||||
|
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||||
|
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||||
|
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||||
|
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||||
|
# Guard against a malformed/missing component so we never emit code 0.
|
||||||
|
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||||
|
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||||
|
echo "version=$VERSION -> versionCode=$CODE"
|
||||||
|
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||||
|
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||||
|
else
|
||||||
|
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
cat "$PROPS"
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
@@ -210,7 +297,7 @@ jobs:
|
|||||||
create-release:
|
create-release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: [build-linux, build-android]
|
needs: [build-linux, build-windows, build-android]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
@@ -230,6 +317,12 @@ jobs:
|
|||||||
name: jellytau-linux
|
name: jellytau-linux
|
||||||
path: artifacts/linux/
|
path: artifacts/linux/
|
||||||
|
|
||||||
|
- name: Download Windows artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellytau-windows
|
||||||
|
path: artifacts/windows/
|
||||||
|
|
||||||
- name: Download Android artifacts
|
- name: Download Android artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -248,6 +341,9 @@ jobs:
|
|||||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
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 "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||||
echo "" >> 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 "#### Android" >> release_notes.md
|
||||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> 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
|
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||||
@@ -329,7 +425,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "Release id=$RELEASE_ID"
|
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
|
[ -f "$f" ] || continue
|
||||||
echo "⬆️ Uploading $(basename "$f")"
|
echo "⬆️ Uploading $(basename "$f")"
|
||||||
curl -fsS -X POST \
|
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:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Bun
|
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||||
uses: oven-sh/setup-bun@v1
|
# action needed — fetching it stalls on this Gitea runner.
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
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)
|
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||||
src-tauri/.cargo/config.toml
|
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,280 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
# 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
|
FROM ubuntu:24.04 AS builder
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
@@ -108,6 +115,30 @@ RUN cd src-tauri && cargo fetch && cd .. && \
|
|||||||
bun run tauri android build --apk true && \
|
bun run tauri android build --apk true && \
|
||||||
echo "APK build complete!"
|
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
|
# Final output stage
|
||||||
FROM ubuntu:24.04 AS final
|
FROM ubuntu:24.04 AS final
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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"]
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# JellyTau Builder Image
|
# 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 .
|
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
@@ -83,6 +87,34 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|||||||
# Set NDK environment variable
|
# Set NDK environment variable
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/bash"]
|
ENTRYPOINT ["/bin/bash"]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"@vitest/ui": "^4.0.16",
|
"@vitest/ui": "^4.0.16",
|
||||||
"@wdio/cli": "^9.5.0",
|
"@wdio/cli": "^9.5.0",
|
||||||
"@wdio/local-runner": "^9.5.0",
|
"@wdio/local-runner": "^9.5.0",
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": ">=1.0.0 <5.0.0",
|
||||||
"webdriverio": "^9.5.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/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/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/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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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/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=="],
|
"@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-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": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
|
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
|
||||||
@@ -498,6 +511,8 @@
|
|||||||
|
|
||||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
"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=="],
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"@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=="],
|
"@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/@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/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=="],
|
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||||
|
|
||||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
"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/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": ["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=="],
|
"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/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/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=="],
|
"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/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=="],
|
"@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/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-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/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-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/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-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/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=="],
|
"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=="],
|
"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/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/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=="],
|
"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/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/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=="],
|
"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:
|
ports:
|
||||||
- "5172:5172" # In case you want to run dev server
|
- "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
|
# Development container - for interactive development
|
||||||
dev:
|
dev:
|
||||||
build:
|
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:**
|
**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 |
|
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||||
|--------------|-----------|----------------|----------------|
|
|--------------|-----------|----------------|----------------|
|
||||||
| Tracks | List (forced) | No | `TrackList` |
|
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Artists | User preference | Yes | `LibraryGrid` |
|
||||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Albums | User preference | Yes | `LibraryGrid` |
|
||||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||||
|
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||||
|
|
||||||
**TrackList Component:**
|
**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
|
## Playback Reporting Service
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,50 @@ flowchart LR
|
|||||||
|
|
||||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||||
|
|
||||||
|
## HTML5 Video Adapter (webview-rendered video)
|
||||||
|
|
||||||
|
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||||
|
`src-tauri/src/commands/player/timers.rs`
|
||||||
|
|
||||||
|
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||||
|
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||||
|
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||||
|
the real player, living outside Rust's reach.
|
||||||
|
|
||||||
|
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
||||||
|
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
||||||
|
authority:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Webview["Webview"]
|
||||||
|
Video["HTML5 <video> / HLS.js"]
|
||||||
|
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||||||
|
end
|
||||||
|
subgraph Backend["Rust"]
|
||||||
|
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||||||
|
Controller["PlayerController"]
|
||||||
|
Emitter["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
Events["playerEvents.ts"]
|
||||||
|
Store["player store"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
||||||
|
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
||||||
|
another event source feeding the existing pipeline.
|
||||||
|
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||||||
|
60fps RAF loop.
|
||||||
|
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
||||||
|
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
||||||
|
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
||||||
|
("frontend only displays state and invokes commands") for the video path.
|
||||||
|
|
||||||
## MpvBackend (Linux)
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
**Location**: `src-tauri/src/player/mpv/`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -37,7 +37,7 @@ For a narrative overview of the system design, see
|
|||||||
| UR-024 | View recently added content on server | Medium | Done |
|
| UR-024 | View recently added content on server | Medium | Done |
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | 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-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-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-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||||
@@ -50,6 +50,25 @@ For a narrative overview of the system design, see
|
|||||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
| 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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,10 +100,15 @@ External system integrations and platform-specific implementations.
|
|||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | 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-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-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-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
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
@@ -123,6 +147,7 @@ API endpoints and data contracts required for Jellyfin integration.
|
|||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
| 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-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | 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
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
@@ -161,7 +186,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-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-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-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-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-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 |
|
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||||
@@ -180,6 +205,40 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
| 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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -198,7 +257,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
| UR-009 | IR-009, IR-010, IR-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-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||||
| UR-012 | IR-009, IR-014 | - |
|
| UR-012 | IR-009, IR-014 | - |
|
||||||
| UR-013 | IR-013 | DR-017 |
|
| UR-013 | IR-013 | DR-017 |
|
||||||
@@ -228,6 +287,25 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| 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 +373,28 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
| 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-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-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
|
### Integration Tests
|
||||||
|
|
||||||
@@ -312,6 +412,9 @@ Internal architecture, components, and application logic.
|
|||||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
| 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-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | 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
|
## 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
|
- ✅ Automatic trace extraction
|
||||||
- ✅ Coverage validation against minimum threshold (50%)
|
- ✅ Coverage validation against minimum threshold (50%)
|
||||||
- ✅ Modified file checking
|
- ✅ Modified file checking
|
||||||
- ✅ Artifact preservation
|
- ✅ Artifact preservation
|
||||||
- ✅ Summary reports
|
- ✅ Summary reports
|
||||||
|
|
||||||
**Runs on:** Every push and pull request
|
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||||
|
|
||||||
### 2. `traceability.yml` (Alternative)
|
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||||
GitHub-compatible workflow with additional features:
|
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||||
- Pull request comments with coverage stats
|
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||||
- GitHub-specific integrations
|
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
|
## What Gets Validated
|
||||||
|
|
||||||
|
|||||||
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
|||||||
- Logo (links to `/library`)
|
- Logo (links to `/library`)
|
||||||
- Navigation links: Home, Library, Downloads, Settings
|
- Navigation links: Home, Library, Downloads, Settings
|
||||||
- Search bar (inline)
|
- Search bar (inline)
|
||||||
- User menu: Username, Downloads icon, Logout button
|
- Account menu (see §1.2)
|
||||||
|
|
||||||
**Mobile Navigation:**
|
**Mobile Navigation:**
|
||||||
|
|
||||||
On mobile, the header contains:
|
On mobile, the header contains:
|
||||||
- Logo
|
- Logo
|
||||||
- Three-dot overflow menu button (Android-style)
|
- Account menu button (see §1.2)
|
||||||
- Overflow menu includes:
|
|
||||||
- Downloads
|
### 1.2 Account Menu
|
||||||
- Settings
|
|
||||||
- Sign out
|
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:**
|
**Access Points Summary:**
|
||||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
- **Downloads** → header icon (desktop) + account menu (both)
|
||||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
- **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
|
```mermaid
|
||||||
flowchart TB
|
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]
|
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]
|
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
|
```mermaid
|
||||||
flowchart TB
|
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?}
|
Grid --> Toggle[View toggle in page header]
|
||||||
|
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||||
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]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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 ──────────────────────────── │
|
│ Songs ──────────────────────────── │
|
||||||
│ ♪ Song Title - Artist 3:45 │
|
│ ♪ Song Title - Artist 3:45 │
|
||||||
│ ♪ Song Title - Artist 4:12 │
|
│ ♪ Song Title - Artist 4:12 │
|
||||||
│ See all (23) │
|
│ See all (23) │
|
||||||
│ │
|
│ │
|
||||||
│ Albums ─────────────────────────── │
|
│ Albums ─────────────────────────── │
|
||||||
│ [Album Cover] Album Title │
|
│ [Cover] Album Title │
|
||||||
│ [Album Cover] Album Title │
|
│ See all (8) │
|
||||||
│ See all (8) │
|
|
||||||
│ │
|
│ │
|
||||||
│ Artists ────────────────────────── │
|
│ Artists ────────────────────────── │
|
||||||
│ [Photo] Artist Name │
|
│ ( Photo ) Artist Name │
|
||||||
│ See all (5) │
|
│ 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
|
## 7. Download Flows
|
||||||
@@ -532,68 +918,166 @@ States:
|
|||||||
5. [⏸] Paused - Yellow pause icon
|
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
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
User[User] --> NavChoice{Navigation Path}
|
Nav[Open Downloads] --> Downloads[/downloads]
|
||||||
|
|
||||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
Downloads --> View{View}
|
||||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
View -->|Transfers| Activity[Transfer activity list]
|
||||||
|
|
||||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||||
HeaderIcon --> DownloadsPage
|
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||||
TypeURL --> DownloadsPage
|
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]
|
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||||
|
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||||
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]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Navigation to Downloads:**
|
**Why reuse the library screens (not a bespoke list):**
|
||||||
- **Desktop:** Click "Downloads" link in header navigation
|
|
||||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
- **One mental model.** Browsing offline should feel identical to browsing
|
||||||
- **Direct:** Navigate to `/downloads` route
|
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 │
|
│ Downloads │
|
||||||
│ │
|
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||||
│ [Active (3)] [Completed (12)] │
|
│ │
|
||||||
│ │
|
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||||
│ ─ Downloading ──────────────────── │
|
│ │
|
||||||
│ │
|
│ Music │ ← only libraries that
|
||||||
│ Album Cover Album Title │
|
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||||
│ Artist Name │
|
│ │alb ││alb ││art │ │
|
||||||
│ [████████░░] 80% │
|
│ └────┘└────┘└────┘ │
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
│ │
|
||||||
│ │
|
│ TV │
|
||||||
│ Album Cover Album Title │
|
│ ┌────┐┌────┐ │
|
||||||
│ Artist Name │
|
│ │show││show│ │
|
||||||
│ [██░░░░░░░░] 20% │
|
│ └────┘└────┘ │
|
||||||
│ [⏸ Pause] [✕ Cancel] │
|
└─────────────────────────────────────────────┘
|
||||||
│ │
|
|
||||||
│ ─ Queued ───────────────────────── │
|
|
||||||
│ │
|
|
||||||
│ Album Cover Album Title │
|
|
||||||
│ Artist Name │
|
|
||||||
│ Waiting... │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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
|
## 8. Settings & Account Flows
|
||||||
@@ -627,6 +1111,13 @@ flowchart TB
|
|||||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||||
- **Direct:** Navigate to `/settings` route
|
- **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
|
### 8.2 Logout Flow
|
||||||
|
|
||||||
```mermaid
|
```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
|
```mermaid
|
||||||
flowchart TB
|
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]
|
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification (§9.1)]
|
||||||
Background -->|Screen Lock| AutoPause
|
|
||||||
|
|
||||||
AutoPause --> SaveProgress[Save Progress]
|
Gate -->|Yes| Mode{Background mode armed?}
|
||||||
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
|
||||||
|
|
||||||
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]
|
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> (reflects live player state)]
|
||||||
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||||
"test:all": "./scripts/test-all.sh",
|
"test:all": "./scripts/test-all.sh",
|
||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"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",
|
"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:dev": "./scripts/build-and-deploy.sh",
|
||||||
"android:check": "./scripts/check-android.sh",
|
"android:check": "./scripts/check-android.sh",
|
||||||
"android:logs": "./scripts/logcat.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",
|
"clean": "./scripts/clean.sh",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"traces": "bun run scripts/extract-traces.ts",
|
"traces": "bun run scripts/extract-traces.ts",
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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
|
||||||
@@ -3,15 +3,19 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
BUILD_TYPE="${1:-debug}"
|
|
||||||
|
|
||||||
echo "🚀 Build and Deploy Android APK"
|
echo "🚀 Build and Deploy Android APK"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build APK
|
# Pass all args (build type and/or --clean) through to the build script.
|
||||||
./scripts/build-android.sh "$BUILD_TYPE"
|
./scripts/build-android.sh "$@"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Deploy APK
|
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||||
|
|||||||
@@ -15,13 +15,24 @@ echo "Android SDK: $ANDROID_HOME"
|
|||||||
echo "NDK: $NDK_HOME"
|
echo "NDK: $NDK_HOME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
BUILD_TYPE="${1:-debug}"
|
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||||
|
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
CLEAN="${CLEAN:-0}"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--clean) CLEAN=1 ;;
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Step 0: Clear build caches to ensure fresh builds
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
echo "🧹 Clearing build caches..."
|
if [ "$CLEAN" = "1" ]; then
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
echo "🧹 Clearing build caches (clean build)..."
|
||||||
npm install > /dev/null 2>&1
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
|
npm install > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
@@ -33,6 +44,9 @@ bun run build
|
|||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
# Configure release signing from .env (single source of truth). Must run
|
||||||
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,4 +41,71 @@ if [ -f "$APP_GRADLE_SRC" ]; then
|
|||||||
echo " Copied: app/build.gradle.kts"
|
echo " Copied: app/build.gradle.kts"
|
||||||
fi
|
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.
|
||||||
|
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
|
||||||
|
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
|
||||||
|
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
|
||||||
|
if [ -f "$PROGUARD_SRC" ]; then
|
||||||
|
cp "$PROGUARD_SRC" "$PROGUARD_DST"
|
||||||
|
echo " Copied: app/proguard-jellytau.pro"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
|
||||||
|
# low-quality launcher icons from tauri.conf.json (which has no high-res
|
||||||
|
# Android source), so overwrite them with the real committed mipmaps.
|
||||||
|
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
|
||||||
|
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
|
||||||
|
if [ -d "$RES_SRC" ]; then
|
||||||
|
for dir in "$RES_SRC"/mipmap-*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
mkdir -p "$RES_DST/$name"
|
||||||
|
cp "$dir"/* "$RES_DST/$name/"
|
||||||
|
echo " Copied res: $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# values/ (themes.xml): status-bar styling that `tauri android init` does
|
||||||
|
# not generate. Previously this directory was tracked but never copied, so
|
||||||
|
# the theme customizations below never reached a build.
|
||||||
|
if [ -d "$RES_SRC/values" ]; then
|
||||||
|
mkdir -p "$RES_DST/values"
|
||||||
|
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||||
|
echo " Copied res: values"
|
||||||
|
fi
|
||||||
|
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||||
|
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||||
|
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||||
|
# ic_launcher_monochrome.png would just be dead weight.
|
||||||
|
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
|
||||||
|
|
||||||
|
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
|
||||||
|
# as API-qualified VECTOR drawables:
|
||||||
|
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
|
||||||
|
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
|
||||||
|
# Because drawable-v24 is a more specific match than our unqualified
|
||||||
|
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
|
||||||
|
# the green square robot instead of our jellyfish. Remove them so the
|
||||||
|
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
|
||||||
|
# to the real committed PNGs.
|
||||||
|
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
|
||||||
|
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
|
||||||
|
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
|
||||||
|
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
|
||||||
|
#
|
||||||
|
# .env is the single source of truth for local release signing. `tauri android
|
||||||
|
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
|
||||||
|
# from .env before every release build (this is the local mirror of what the CI
|
||||||
|
# workflow does from Gitea secrets).
|
||||||
|
#
|
||||||
|
# Required .env vars:
|
||||||
|
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
|
||||||
|
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "❌ $ENV_FILE not found — cannot configure release signing." >&2
|
||||||
|
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
|
||||||
|
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load .env without leaking it into the caller's environment beyond what we need.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
|
||||||
|
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
|
||||||
|
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
|
||||||
|
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
|
||||||
|
|
||||||
|
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
|
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$PROPS")"
|
||||||
|
umask 077
|
||||||
|
cat > "$PROPS" <<EOF
|
||||||
|
storeFile=$ANDROID_KEYSTORE_FILE
|
||||||
|
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||||
|
keyAlias=$ANDROID_KEY_ALIAS
|
||||||
|
keyPassword=$ANDROID_KEY_PASSWORD
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# JellyTau custom keep rules.
|
||||||
|
#
|
||||||
|
# These classes are loaded by name from the Rust backend via JNI
|
||||||
|
# (env.find_class / class-loader lookups), so R8 cannot see the
|
||||||
|
# references and would otherwise strip or rename them in a minified
|
||||||
|
# release build — causing an instant ClassNotFoundException crash on
|
||||||
|
# startup. See src-tauri/src/player/android/mod.rs and
|
||||||
|
# src-tauri/src/credentials.rs.
|
||||||
|
-keep class com.dtourolle.jellytau.player.** { *; }
|
||||||
|
-keep class com.dtourolle.jellytau.security.** { *; }
|
||||||
|
|
||||||
|
# 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"?>
|
<?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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- Enable hardware acceleration for video playback performance -->
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<application android:hardwareAccelerated="true" />
|
<!-- 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>
|
</manifest>
|
||||||
|
|||||||
@@ -22,6 +22,34 @@ class MainActivity : TauriActivity() {
|
|||||||
private var audioFocusRequest: AudioFocusRequest? = null
|
private var audioFocusRequest: AudioFocusRequest? = null
|
||||||
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -37,6 +65,80 @@ class MainActivity : TauriActivity() {
|
|||||||
configureWebViewForMedia()
|
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() {
|
private fun configureWebViewForMedia() {
|
||||||
try {
|
try {
|
||||||
val webView = findWebView(window.decorView)
|
val webView = findWebView(window.decorView)
|
||||||
@@ -55,6 +157,7 @@ class MainActivity : TauriActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
|
mediaWebView = webView
|
||||||
|
|
||||||
// Add JavaScript interface for audio focus control
|
// Add JavaScript interface for audio focus control
|
||||||
webView.addJavascriptInterface(object : Any() {
|
webView.addJavascriptInterface(object : Any() {
|
||||||
@@ -70,6 +173,81 @@ class MainActivity : TauriActivity() {
|
|||||||
}, "AndroidAudioFocus")
|
}, "AndroidAudioFocus")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
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
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
webView.webChromeClient = object : WebChromeClient() {
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
@@ -93,7 +271,6 @@ class MainActivity : TauriActivity() {
|
|||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
allowFileAccess = true
|
allowFileAccess = true
|
||||||
allowContentAccess = true
|
allowContentAccess = true
|
||||||
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -187,6 +187,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
override fun onSeekTo(position: Long) {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
|
// 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
|
val positionSeconds = position / 1000.0
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
@@ -259,6 +262,25 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
private var lastArtist: String = ""
|
private var lastArtist: String = ""
|
||||||
private var lastIsPlaying: Boolean = false
|
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.
|
* Update the MediaSession metadata and playback state, plus the notification.
|
||||||
*
|
*
|
||||||
@@ -292,8 +314,16 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
|
|
||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state
|
// Update MediaSession playback state (position made absolute via the base offset).
|
||||||
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||||
|
|
||||||
|
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||||
|
// arrive on the session poller thread and can race with (or arrive
|
||||||
|
// before) enableRemoteVolume(); this keeps the session routed to the
|
||||||
|
// remote (absolute) volume slider instead of the local media stream.
|
||||||
|
if (isRemoteVolumeEnabled) {
|
||||||
|
volumeProvider?.let { session.setPlaybackToRemote(it) }
|
||||||
|
}
|
||||||
|
|
||||||
// Update the notification
|
// Update the notification
|
||||||
updateNotification(title, artist, isPlaying)
|
updateNotification(title, artist, isPlaying)
|
||||||
@@ -314,7 +344,8 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||||
lastIsPlaying = isPlaying
|
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.
|
// Only rebuild the notification when the play/pause icon actually flips.
|
||||||
if (notificationStateChanged) {
|
if (notificationStateChanged) {
|
||||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||||
@@ -326,8 +357,17 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
*
|
*
|
||||||
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||||
* Android does not extrapolate the position past a paused track.
|
* Android does not extrapolate the position past a paused track.
|
||||||
|
*
|
||||||
|
* While remote volume control is enabled (casting), the state is forced to
|
||||||
|
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
|
||||||
|
* (absolute) volume slider for a session that is actively playing; if a
|
||||||
|
* periodic metadata/position push reports paused (e.g. before the remote
|
||||||
|
* session has actually started), reporting STATE_PAUSED here makes the
|
||||||
|
* system tear down the remote slider set up by setPlaybackToRemote() and
|
||||||
|
* fall back to the local media-stream volume.
|
||||||
*/
|
*/
|
||||||
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||||
|
val playing = isPlaying || isRemoteVolumeEnabled
|
||||||
return PlaybackStateCompat.Builder()
|
return PlaybackStateCompat.Builder()
|
||||||
.setActions(
|
.setActions(
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
@@ -338,9 +378,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PlaybackStateCompat.ACTION_SEEK_TO
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
)
|
)
|
||||||
.setState(
|
.setState(
|
||||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
position,
|
position,
|
||||||
if (isPlaying) 1.0f else 0.0f
|
if (playing) 1.0f else 0.0f
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 870 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::jellyfin::http_client::HttpClient;
|
|
||||||
use crate::connectivity::ConnectivityMonitor;
|
use crate::connectivity::ConnectivityMonitor;
|
||||||
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
|
||||||
pub use session_verifier::SessionVerifier;
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
@@ -99,7 +99,10 @@ impl AuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set the connectivity monitor (for marking server reachability)
|
/// 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);
|
self.connectivity_monitor = Some(monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +136,17 @@ impl AuthManager {
|
|||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
|
|
||||||
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
|
match self
|
||||||
|
.http_client
|
||||||
|
.get_json_fast::<PublicSystemInfo>(&endpoint)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
log::info!(
|
||||||
|
"[AuthManager] Connected to server: {} ({})",
|
||||||
|
info.server_name,
|
||||||
|
info.version
|
||||||
|
);
|
||||||
|
|
||||||
// Mark server as reachable
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
@@ -181,7 +192,10 @@ impl AuthManager {
|
|||||||
let auth_header = HttpClient::build_auth_header(None, device_id);
|
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||||
|
|
||||||
// Build request manually for custom headers
|
// 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("Content-Type", "application/json")
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
@@ -192,19 +206,31 @@ impl AuthManager {
|
|||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
// Use retry logic
|
// 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))?;
|
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
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));
|
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))?;
|
.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
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
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);
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
// Build request manually for custom headers
|
// 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)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
// Use retry logic
|
// 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| {
|
.map_err(|e| {
|
||||||
log::warn!("[AuthManager] Session verification failed: {}", e);
|
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||||
format!("Session verification failed: {}", e)
|
format!("Session verification failed: {}", e)
|
||||||
@@ -257,24 +289,34 @@ impl AuthManager {
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
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
|
// Mark server as unreachable for auth errors
|
||||||
if status.as_u16() == 401 || status.as_u16() == 403 {
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||||
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
let monitor = monitor.lock().await;
|
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));
|
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))?;
|
.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
|
// Mark server as reachable
|
||||||
if let Some(monitor) = &self.connectivity_monitor {
|
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);
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
let request = self.http_client.client.post(&endpoint)
|
let request = self
|
||||||
|
.http_client
|
||||||
|
.client
|
||||||
|
.post(&endpoint)
|
||||||
.header("X-Emby-Authorization", auth_header)
|
.header("X-Emby-Authorization", auth_header)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
.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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::{AuthManager, User};
|
use super::{AuthManager, User};
|
||||||
|
|
||||||
@@ -65,7 +65,10 @@ impl SessionVerifier {
|
|||||||
let session = auth_manager.get_session().await;
|
let session = auth_manager.get_session().await;
|
||||||
|
|
||||||
if let Some(session) = session {
|
if let Some(session) = session {
|
||||||
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
log::debug!(
|
||||||
|
"[SessionVerifier] Verifying session for: {}",
|
||||||
|
session.username
|
||||||
|
);
|
||||||
|
|
||||||
// Verify the session
|
// Verify the session
|
||||||
match auth_manager
|
match auth_manager
|
||||||
@@ -113,7 +116,10 @@ impl SessionVerifier {
|
|||||||
reason: "Session expired".to_string(),
|
reason: "Session expired".to_string(),
|
||||||
};
|
};
|
||||||
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
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(),
|
message: e.clone(),
|
||||||
};
|
};
|
||||||
if let Err(e) = app.emit("auth:network-error", event) {
|
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 {
|
} else {
|
||||||
// Unknown error - log but don't invalidate
|
// 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 std::sync::Arc;
|
||||||
use tauri::State;
|
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
|
/// Wrapper for AuthManager to manage in Tauri state
|
||||||
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||||
@@ -27,17 +31,18 @@ pub async fn auth_initialize(
|
|||||||
log::info!("[AuthManager] Restoring session from storage...");
|
log::info!("[AuthManager] Restoring session from storage...");
|
||||||
|
|
||||||
// Use the existing storage_get_active_session function
|
// Use the existing storage_get_active_session function
|
||||||
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
let active_session =
|
||||||
Ok(Some(session)) => session,
|
match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||||
Ok(None) => {
|
Ok(Some(session)) => session,
|
||||||
log::info!("[AuthManager] No active session in storage");
|
Ok(None) => {
|
||||||
return Ok(None);
|
log::info!("[AuthManager] No active session in storage");
|
||||||
}
|
return Ok(None);
|
||||||
Err(e) => {
|
}
|
||||||
log::error!("[AuthManager] Failed to get active session: {}", e);
|
Err(e) => {
|
||||||
return Err(e);
|
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||||
}
|
return Err(e);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create session object from active session with normalized URL
|
// Create session object from active session with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_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
|
// Store in AuthManager
|
||||||
auth_manager.0.set_session(Some(session.clone())).await;
|
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))
|
Ok(Some(session))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,10 @@ pub async fn auth_login(
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<AuthResult, String> {
|
) -> 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
|
// Create session from auth result with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
@@ -111,7 +123,11 @@ pub async fn auth_verify_session(
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> 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),
|
Ok(_) => Ok(true),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||||
@@ -138,7 +154,10 @@ pub async fn auth_logout(
|
|||||||
drop(verifier_guard);
|
drop(verifier_guard);
|
||||||
|
|
||||||
// Call Jellyfin logout endpoint
|
// 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
|
// Clear session
|
||||||
auth_manager.0.set_session(None).await;
|
auth_manager.0.set_session(None).await;
|
||||||
@@ -228,11 +247,22 @@ pub async fn auth_reauthenticate(
|
|||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
// Get current session to extract server_url and username
|
// 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())?;
|
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||||
|
|
||||||
// Re-login with stored credentials
|
// 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
|
// Update session with new token
|
||||||
let updated_session = Session {
|
let updated_session = Session {
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
//! Tauri commands for the offline "browse & queue" feature.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-002, UR-007, UR-024 | JA-004, JA-016 | DR-012, DR-027
|
||||||
|
//!
|
||||||
|
//! Two backend pieces support browsing the full server catalog while offline
|
||||||
|
//! and queueing downloads that fire on reconnect:
|
||||||
|
//!
|
||||||
|
//! - [`sync_full_catalog`] walks every library while online and persists all
|
||||||
|
//! items to the offline cache so the whole catalog is browsable (greyed out)
|
||||||
|
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
|
||||||
|
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
|
||||||
|
//! what `get_items` branch 3 serves offline).
|
||||||
|
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
|
||||||
|
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||||
|
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// app_settings key holding the RFC-3339 timestamp of the last successful
|
||||||
|
/// full-catalog sync.
|
||||||
|
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||||
|
|
||||||
|
/// Item types worth caching for offline browsing: containers the library
|
||||||
|
/// landing pages render plus the playable leaves users queue for download.
|
||||||
|
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||||
|
"MusicAlbum",
|
||||||
|
"Movie",
|
||||||
|
"Series",
|
||||||
|
"Season",
|
||||||
|
"Episode",
|
||||||
|
"Audio",
|
||||||
|
"BoxSet",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncResult {
|
||||||
|
/// Total items persisted to the offline cache across all libraries.
|
||||||
|
pub items_cached: usize,
|
||||||
|
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||||
|
pub libraries_failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncStatus {
|
||||||
|
/// RFC-3339 timestamp of the last successful sync, if any.
|
||||||
|
pub last_synced_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk every library on the server and persist all items to the offline cache
|
||||||
|
/// so the full catalog is browsable offline (greyed out when not downloaded).
|
||||||
|
///
|
||||||
|
/// Best-effort: a library that fails to fetch is counted and skipped rather than
|
||||||
|
/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
|
||||||
|
/// server. Uses `Recursive=true` so a single request per library returns the
|
||||||
|
/// containers and their playable children.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn sync_full_catalog(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<CatalogSyncResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||||
|
info!(
|
||||||
|
"[Catalog] Full sync starting across {} libraries",
|
||||||
|
libraries.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let mut items_cached = 0usize;
|
||||||
|
let mut libraries_failed = 0usize;
|
||||||
|
|
||||||
|
for library in &libraries {
|
||||||
|
let opts = GetItemsOptions {
|
||||||
|
recursive: Some(true),
|
||||||
|
include_item_types: Some(include_types.clone()),
|
||||||
|
limit: Some(100_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.cache_items_from_server(&library.id, Some(opts)).await {
|
||||||
|
Ok(items) => {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Cached {} items from library '{}'",
|
||||||
|
items.len(),
|
||||||
|
library.name
|
||||||
|
);
|
||||||
|
items_cached += items.len();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to sync library '{}': {:?}",
|
||||||
|
library.name, e
|
||||||
|
);
|
||||||
|
libraries_failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let upsert = Query::with_params(
|
||||||
|
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
|
||||||
|
QueryParam::String(now),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(upsert).await {
|
||||||
|
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||||
|
items_cached, libraries_failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(CatalogSyncResult {
|
||||||
|
items_cached,
|
||||||
|
libraries_failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||||
|
/// to trigger a fresh sync.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn catalog_sync_status(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
) -> Result<CatalogSyncStatus, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||||
|
);
|
||||||
|
let last_synced_at: Option<String> = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(CatalogSyncStatus { last_synced_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control whether offline library queries reveal the full synced catalog
|
||||||
|
/// (greyed-out, non-downloaded media) or only downloaded/local media.
|
||||||
|
///
|
||||||
|
/// The frontend calls this from the "Show all server media" toggle: pass `true`
|
||||||
|
/// when online, or when offline with the toggle on; pass `false` when offline
|
||||||
|
/// with the toggle off so library pages show downloaded media only. Fixes the
|
||||||
|
/// bug where offline library pages showed every server item regardless of the
|
||||||
|
/// toggle.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn set_show_server_catalog(show: bool) {
|
||||||
|
crate::repository::offline::set_include_catalog_browse(show);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumeQueuedResult {
|
||||||
|
/// Rows whose stream URL was resolved and are now pump-eligible.
|
||||||
|
pub resolved: usize,
|
||||||
|
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||||
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
|
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||||
|
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
target_dir: &str,
|
||||||
|
resolve: F,
|
||||||
|
) -> Result<ResumeQueuedResult, String>
|
||||||
|
where
|
||||||
|
F: Fn(String, String, String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Option<String>>,
|
||||||
|
{
|
||||||
|
let rows_query = Query::new(
|
||||||
|
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||||
|
FROM downloads
|
||||||
|
WHERE status = 'pending' AND stream_url IS NULL",
|
||||||
|
);
|
||||||
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
|
.query_many(rows_query, |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(ResumeQueuedResult {
|
||||||
|
resolved: 0,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Resolving {} offline-queued downloads on reconnect",
|
||||||
|
rows.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut resolved = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for (download_id, item_id, media_type, quality) in rows {
|
||||||
|
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
||||||
|
Some(url) => url,
|
||||||
|
None => {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
||||||
|
// concurrent resolver doesn't clobber an already-started row.
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET stream_url = ?, target_dir = ?
|
||||||
|
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir.to_string()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
match db_service.execute(update).await {
|
||||||
|
Ok(n) if n > 0 => resolved += 1,
|
||||||
|
Ok(_) => {} // already resolved by someone else; not a failure
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to persist URL for download {}: {}",
|
||||||
|
download_id, e
|
||||||
|
);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the stream URL for every download row that was queued while offline
|
||||||
|
/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
|
||||||
|
/// start. Call this on reconnect.
|
||||||
|
///
|
||||||
|
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
|
||||||
|
/// 'video') via the pure `get_video_download_url` builder using the row's stored
|
||||||
|
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
|
||||||
|
/// be resolved are left pending (they retry on the next reconnect).
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn resume_queued_downloads(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<ResumeQueuedResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
use crate::repository::HybridRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
// The pump needs a target_dir; use the same storage root the other download
|
||||||
|
// paths use (the database's parent directory — see `storage_get_path`).
|
||||||
|
let (db_service, target_dir) = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let target_dir = database
|
||||||
|
.path()
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
(Arc::new(database.service()), target_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recover stale downloads: rows left in 'downloading' when the app was killed
|
||||||
|
// mid-transfer are orphaned — nothing ever restarts them, so they show as
|
||||||
|
// permanently "downloading". Reset them to 'pending' and clear the stale
|
||||||
|
// stream_url so they get re-resolved and restarted from scratch below.
|
||||||
|
let recover_query = Query::new(
|
||||||
|
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
|
||||||
|
bytes_downloaded = 0, started_at = NULL \
|
||||||
|
WHERE status = 'downloading'",
|
||||||
|
);
|
||||||
|
match db_service.execute(recover_query).await {
|
||||||
|
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each row's URL against the (now reachable) repository.
|
||||||
|
let repo_for_resolve = Arc::clone(&repo);
|
||||||
|
let outcome = resolve_pending_download_urls(
|
||||||
|
&db_service,
|
||||||
|
&target_dir,
|
||||||
|
move |item_id: String, media_type: String, quality: String| {
|
||||||
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
|
async move {
|
||||||
|
if media_type == "video" {
|
||||||
|
Some(
|
||||||
|
<HybridRepository as MediaRepository>::get_video_download_url(
|
||||||
|
repo.as_ref(),
|
||||||
|
&item_id,
|
||||||
|
&quality,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Catalog] Failed to resolve audio URL for {}: {:?}",
|
||||||
|
item_id, e
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let ResumeQueuedResult { resolved, failed } = outcome;
|
||||||
|
|
||||||
|
// Kick the pump so the newly-resolved rows actually start.
|
||||||
|
if resolved > 0 {
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Resume complete: {} resolved, {} failed",
|
||||||
|
resolved, failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn test_db() -> Arc<RusqliteService> {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stream_url TEXT,
|
||||||
|
target_dir TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
quality_preset TEXT
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_download(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
item_id: &str,
|
||||||
|
status: &str,
|
||||||
|
stream_url: Option<&str>,
|
||||||
|
media_type: Option<&str>,
|
||||||
|
) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
QueryParam::String(status.to_string()),
|
||||||
|
stream_url
|
||||||
|
.map(|s| QueryParam::String(s.to_string()))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
media_type
|
||||||
|
.map(|s| QueryParam::String(s.to_string()))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db.execute(q).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_row(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
item_id: &str,
|
||||||
|
) -> (String, Option<String>, Option<String>) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String(item_id.to_string())],
|
||||||
|
);
|
||||||
|
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
// A row queued offline: pending with no URL yet.
|
||||||
|
insert_download(&db, "queued-1", "pending", None, None).await;
|
||||||
|
// An already-resolved pending row: must NOT be touched.
|
||||||
|
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
|
||||||
|
// A completed row: irrelevant.
|
||||||
|
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
assert_eq!(out.failed, 0);
|
||||||
|
|
||||||
|
// The offline-queued row now has a URL + target dir and stays pending.
|
||||||
|
let (status, url, target) = get_row(&db, "queued-1").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
|
||||||
|
assert_eq!(target.as_deref(), Some("/data/downloads"));
|
||||||
|
|
||||||
|
// The already-resolved row is unchanged (not re-resolved).
|
||||||
|
let (_s, url2, _t) = get_row(&db, "already").await;
|
||||||
|
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "bad", "pending", None, None).await;
|
||||||
|
|
||||||
|
// Resolver returns None (e.g. server lookup failed).
|
||||||
|
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 0);
|
||||||
|
assert_eq!(out.failed, 1);
|
||||||
|
|
||||||
|
// Still pending with no URL, so a later reconnect can retry it.
|
||||||
|
let (status, url, _t) = get_row(&db, "bad").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_rows_use_media_type_in_resolver() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
|
let out =
|
||||||
|
resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||||
|
assert_eq!(media_type, "video");
|
||||||
|
Some(format!("http://transcode/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||||
|
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
//! Server-reachability / connectivity commands.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-043 | IR-027 | DR-055
|
||||||
|
|
||||||
|
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
|
||||||
|
|
||||||
/// Wrapper for ConnectivityMonitor managed state
|
/// Wrapper for ConnectivityMonitor managed state
|
||||||
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! Tauri commands for unit conversions and formatting
|
//! Tauri commands for unit conversions and formatting
|
||||||
//!
|
//!
|
||||||
|
//! TRACES: UR-005 | DR-009
|
||||||
|
//!
|
||||||
//! These commands expose conversion utilities to the frontend,
|
//! These commands expose conversion utilities to the frontend,
|
||||||
//! allowing centralized conversion logic in Rust.
|
//! allowing centralized conversion logic in Rust.
|
||||||
|
|
||||||
use crate::utils::conversions::{
|
use crate::utils::conversions::{
|
||||||
format_time, format_time_long, calculate_progress,
|
calculate_progress, format_time, format_time_long, percent_to_volume, ticks_to_seconds,
|
||||||
ticks_to_seconds, percent_to_volume,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Format time in seconds to MM:SS display string
|
/// 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
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::{Manager, State};
|
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::download::{DownloadInfo, DownloadManager};
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
|
||||||
|
|
||||||
// Cohesive command clusters in their own submodules, re-exported so the command
|
// Cohesive command clusters in their own submodules, re-exported so the command
|
||||||
// names remain at `commands::download::*` (invoke_handler unchanged).
|
// 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
|
/// Wrapper for DownloadManager to be used as Tauri state
|
||||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
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
|
/// Download statistics computed server-side
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -111,7 +186,13 @@ pub async fn download_item_and_start(
|
|||||||
request: DownloadItemAndStartRequest,
|
request: DownloadItemAndStartRequest,
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
let DownloadItemAndStartRequest {
|
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;
|
} = request;
|
||||||
// Sanitize filename
|
// Sanitize filename
|
||||||
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
|
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,
|
album_name,
|
||||||
expected_size: None,
|
expected_size: None,
|
||||||
},
|
},
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Start the download immediately
|
// Start the download immediately
|
||||||
start_download(
|
start_download(
|
||||||
@@ -142,7 +224,8 @@ pub async fn download_item_and_start(
|
|||||||
download_id,
|
download_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
target_dir,
|
target_dir,
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(download_id)
|
Ok(download_id)
|
||||||
}
|
}
|
||||||
@@ -156,7 +239,15 @@ pub async fn download_item(
|
|||||||
request: DownloadItemRequest,
|
request: DownloadItemRequest,
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
let DownloadItemRequest {
|
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;
|
} = request;
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
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
|
// 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 {
|
if !can_download {
|
||||||
warn!("Storage limit reached. Attempting to free space...");
|
warn!("Storage limit reached. Attempting to free space...");
|
||||||
|
|
||||||
// Try to evict LRU items to make 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 => {
|
Ok(freed) if freed > 0 => {
|
||||||
info!("Freed {} bytes, proceeding with download", freed);
|
info!("Freed {} bytes, proceeding with download", freed);
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
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!(
|
return Err(format!(
|
||||||
"Storage limit reached ({} bytes). Unable to free enough space.",
|
"Storage limit reached ({} bytes). Unable to free enough space.",
|
||||||
storage_limit
|
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
|
// 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
|
// 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)
|
// Query for the actual download ID (last_insert_rowid doesn't work with UPSERT)
|
||||||
let id_query = Query::with_params(
|
let id_query = Query::with_params(
|
||||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
"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
|
let download_id: i64 = db_service
|
||||||
@@ -317,8 +423,17 @@ pub async fn download_video(
|
|||||||
request: DownloadVideoRequest,
|
request: DownloadVideoRequest,
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
let DownloadVideoRequest {
|
let DownloadVideoRequest {
|
||||||
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
|
item_id,
|
||||||
series_name, season_name, episode_number, season_number,
|
user_id,
|
||||||
|
file_path,
|
||||||
|
mime_type,
|
||||||
|
priority,
|
||||||
|
item_name,
|
||||||
|
quality_preset,
|
||||||
|
series_name,
|
||||||
|
season_name,
|
||||||
|
episode_number,
|
||||||
|
season_number,
|
||||||
} = request;
|
} = request;
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
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
|
// Query for the download ID by unique constraint columns
|
||||||
let id_query = Query::with_params(
|
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
|
let episodes: Vec<(String, String, Option<String>, Option<i32>, Option<i32>)> = db_service
|
||||||
.query_many(episodes_query, |row| {
|
.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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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)
|
// Queue each episode with descending priority (first episodes download first)
|
||||||
// Priority starts high and decreases so earlier episodes finish first
|
// Priority starts high and decreases so earlier episodes finish first
|
||||||
let total_episodes = episodes.len() as i32;
|
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
|
let priority = 1000 - idx as i32; // High priority for first episodes
|
||||||
|
|
||||||
// Create path like: videos/SeriesName/S01E01_Title.mp4
|
// Create path like: videos/SeriesName/S01E01_Title.mp4
|
||||||
@@ -425,7 +551,12 @@ pub async fn download_series(
|
|||||||
episode_num,
|
episode_num,
|
||||||
sanitize_filename(&episode_name)
|
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(
|
let insert_query = Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
"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(episode_name),
|
||||||
QueryParam::String(quality.clone()),
|
QueryParam::String(quality.clone()),
|
||||||
QueryParam::String(series_name.clone()),
|
QueryParam::String(series_name.clone()),
|
||||||
season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
season_name
|
||||||
episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
.map(QueryParam::String)
|
||||||
season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
.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(
|
let id_query = Query::with_params(
|
||||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
"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
|
let download_id: i64 = db_service
|
||||||
@@ -471,7 +614,10 @@ pub async fn download_series(
|
|||||||
download_ids.push(download_id);
|
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)
|
Ok(download_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +670,12 @@ pub async fn download_season(
|
|||||||
episode_num,
|
episode_num,
|
||||||
sanitize_filename(&episode_name)
|
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(
|
let insert_query = Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
|
"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(
|
let id_query = Query::with_params(
|
||||||
"SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
|
"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
|
let download_id: i64 = db_service
|
||||||
@@ -565,11 +722,15 @@ pub async fn download_season(
|
|||||||
download_ids.push(download_id);
|
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)
|
Ok(download_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Helper to compute download statistics from a list of downloads
|
/// Helper to compute download statistics from a list of downloads
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
|
fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
|
||||||
@@ -674,7 +835,10 @@ pub async fn get_downloads(
|
|||||||
/// Pause a download
|
/// Pause a download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -692,7 +856,10 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
|
|||||||
/// Resume a paused download
|
/// Resume a paused download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -738,13 +905,20 @@ pub async fn cancel_download(
|
|||||||
vec![QueryParam::Int64(download_id)],
|
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)
|
// Unregister from download manager (in case it was active)
|
||||||
{
|
{
|
||||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
manager.unregister_download(download_id);
|
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
|
// Delete partial file if exists
|
||||||
@@ -800,7 +974,10 @@ pub async fn mark_download_failed(
|
|||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
"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())?;
|
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() {
|
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());
|
debug!(" Active downloads: {}", manager.active_count());
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
|
"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
|
// Register this download as active
|
||||||
let registered = manager.register_download(download_id);
|
let registered = manager.register_download(download_id);
|
||||||
if !registered {
|
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());
|
return Err("Download already in progress or limit reached".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Download {} registered. Active downloads: {}/{}",
|
info!(
|
||||||
download_id, manager.active_count(), manager.max_concurrent());
|
"Download {} registered. Active downloads: {}/{}",
|
||||||
|
download_id,
|
||||||
|
manager.active_count(),
|
||||||
|
manager.max_concurrent()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get download info from DB
|
// 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
|
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
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("Failed to query download info: {}", e);
|
error!("Failed to query download info: {}", e);
|
||||||
e.to_string()
|
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
|
// Make a HEAD request to get the file size from Content-Length header
|
||||||
debug!("Making HEAD request to get file size...");
|
debug!("Making HEAD request to get file size...");
|
||||||
let head_response = reqwest::Client::new()
|
let head_response = reqwest::Client::new().head(&stream_url).send().await;
|
||||||
.head(&stream_url)
|
|
||||||
.send()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let file_size_from_server = match head_response {
|
let file_size_from_server = match head_response {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
@@ -893,7 +1082,11 @@ pub async fn start_download(
|
|||||||
.and_then(|v| v.parse::<i64>().ok());
|
.and_then(|v| v.parse::<i64>().ok());
|
||||||
|
|
||||||
if let Some(size) = size {
|
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 {
|
} else {
|
||||||
warn!(" Server didn't provide Content-Length header");
|
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
|
// Emit started event
|
||||||
let started_event = DownloadEvent::Started {
|
let started_event = DownloadEvent::Started {
|
||||||
@@ -937,7 +1133,10 @@ pub async fn start_download(
|
|||||||
item_id: item_id.clone(),
|
item_id: item_id.clone(),
|
||||||
};
|
};
|
||||||
debug!("Emitting download-event: {:?}", started_event);
|
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) {
|
match app.emit("download-event", started_event) {
|
||||||
Ok(_) => debug!(" Event emitted successfully"),
|
Ok(_) => debug!(" Event emitted successfully"),
|
||||||
Err(e) => error!(" Event emit failed: {:?}", e),
|
Err(e) => error!(" Event emit failed: {:?}", e),
|
||||||
@@ -998,7 +1197,10 @@ pub async fn enqueue_download(
|
|||||||
QueryParam::Int64(download_id),
|
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.
|
// Kick the pump: it will start as many pending downloads as there are slots.
|
||||||
let active_downloads = {
|
let active_downloads = {
|
||||||
@@ -1056,7 +1258,9 @@ pub async fn enqueue_video_downloads(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the transcode URL (pure URL builder, no server round-trip).
|
// 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(
|
let update_query = Query::with_params(
|
||||||
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
"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 {
|
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(())
|
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.
|
/// Start as many pending downloads as there are free concurrency slots.
|
||||||
///
|
///
|
||||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
/// 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 crate::download::events::DownloadEvent;
|
||||||
use tauri::Emitter;
|
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 max_concurrent = {
|
||||||
let manager = app.state::<DownloadManagerWrapper>();
|
let manager = app.state::<DownloadManagerWrapper>();
|
||||||
let manager = match manager.0.lock() {
|
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
|
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
||||||
.query_many(next_query, |row| {
|
.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
|
.await
|
||||||
{
|
{
|
||||||
@@ -1189,7 +1443,10 @@ pub(crate) async fn pump_download_queue(
|
|||||||
vec![QueryParam::Int64(download_id)],
|
vec![QueryParam::Int64(download_id)],
|
||||||
);
|
);
|
||||||
if let Err(e) = db_service.execute(update_query).await {
|
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() {
|
if let Ok(mut a) = active_downloads.lock() {
|
||||||
a.remove(&download_id);
|
a.remove(&download_id);
|
||||||
}
|
}
|
||||||
@@ -1228,8 +1485,8 @@ fn spawn_download_worker(
|
|||||||
target_path: std::path::PathBuf,
|
target_path: std::path::PathBuf,
|
||||||
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||||
) {
|
) {
|
||||||
use crate::download::{DownloadTask, DownloadWorker};
|
|
||||||
use crate::download::events::DownloadEvent;
|
use crate::download::events::DownloadEvent;
|
||||||
|
use crate::download::{DownloadTask, DownloadWorker};
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
let task = DownloadTask {
|
let task = DownloadTask {
|
||||||
@@ -1265,16 +1522,66 @@ fn spawn_download_worker(
|
|||||||
// Free the slot before pumping so the next download can take it.
|
// Free the slot before pumping so the next download can take it.
|
||||||
if let Ok(mut active) = active_downloads.lock() {
|
if let Ok(mut active) = active_downloads.lock() {
|
||||||
active.remove(&download_id);
|
active.remove(&download_id);
|
||||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
debug!(
|
||||||
|
" Unregistered download {}. Active downloads: {}",
|
||||||
|
download_id,
|
||||||
|
active.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The pump runs downloads in the background, so the terminal status MUST
|
||||||
|
// be persisted to the DB here — the frontend event handler only writes it
|
||||||
|
// when that download happens to be loaded in its store, which is not the
|
||||||
|
// case for auto-pumped rows (or any completion while the downloads page is
|
||||||
|
// closed). `check_for_local_download` filters on status = 'completed', so a
|
||||||
|
// missed write leaves finished files unrecognized: albums never show as
|
||||||
|
// downloaded and playback never switches from the (expiring) stream to the
|
||||||
|
// local file, cutting tracks off mid-play.
|
||||||
|
let db_service = {
|
||||||
|
let db = app.state::<DatabaseWrapper>();
|
||||||
|
let database = match db.0.lock() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"[pump] Failed to lock database after download {}: {}",
|
||||||
|
download_id, e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
info!(
|
||||||
|
"Download completed successfully: {} bytes",
|
||||||
|
res.bytes_downloaded
|
||||||
|
);
|
||||||
|
let file_path = target_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'completed', progress = 1.0, \
|
||||||
|
bytes_downloaded = ?, file_size = ?, file_path = ?, \
|
||||||
|
completed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||||
|
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||||
|
QueryParam::String(file_path.clone()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(update).await {
|
||||||
|
error!(
|
||||||
|
"[pump] Failed to persist completed status for download {}: {}",
|
||||||
|
download_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let completed_event = DownloadEvent::Completed {
|
let completed_event = DownloadEvent::Completed {
|
||||||
download_id,
|
download_id,
|
||||||
item_id,
|
item_id,
|
||||||
file_path: target_path.to_string_lossy().to_string(),
|
file_path,
|
||||||
};
|
};
|
||||||
match app.emit("download-event", completed_event) {
|
match app.emit("download-event", completed_event) {
|
||||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||||
@@ -1283,6 +1590,21 @@ fn spawn_download_worker(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Download failed: {:?}", e);
|
error!("Download failed: {:?}", e);
|
||||||
|
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(e.to_string()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(db_err) = db_service.execute(update).await {
|
||||||
|
error!(
|
||||||
|
"[pump] Failed to persist failed status for download {}: {}",
|
||||||
|
download_id, db_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let failed_event = DownloadEvent::Failed {
|
let failed_event = DownloadEvent::Failed {
|
||||||
download_id,
|
download_id,
|
||||||
item_id,
|
item_id,
|
||||||
@@ -1296,17 +1618,6 @@ fn spawn_download_worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A slot just freed — start the next pending download (if any).
|
// A slot just freed — start the next pending download (if any).
|
||||||
let db_service = {
|
|
||||||
let db = app.state::<DatabaseWrapper>();
|
|
||||||
let database = match db.0.lock() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Arc::new(database.service())
|
|
||||||
};
|
|
||||||
pump_download_queue(app.clone(), db_service, active_downloads).await;
|
pump_download_queue(app.clone(), db_service, active_downloads).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1314,7 +1625,10 @@ fn spawn_download_worker(
|
|||||||
/// Delete a completed download
|
/// Delete a completed download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -1338,7 +1652,10 @@ pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
|
|||||||
vec![QueryParam::Int64(download_id)],
|
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
|
// Delete actual file if exists
|
||||||
if let Some(path) = file_path {
|
if let Some(path) = file_path {
|
||||||
@@ -1375,8 +1692,12 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
|
|||||||
episode_number: row.get(20)?,
|
episode_number: row.get(20)?,
|
||||||
season_number: row.get(21)?,
|
season_number: row.get(21)?,
|
||||||
quality_preset: row.get(22)?,
|
quality_preset: row.get(22)?,
|
||||||
media_type: row.get::<_, Option<String>>(23)?.unwrap_or_else(|| "audio".to_string()),
|
media_type: row
|
||||||
download_source: row.get::<_, Option<String>>(24)?.unwrap_or_else(|| "user".to_string()),
|
.get::<_, Option<String>>(23)?
|
||||||
|
.unwrap_or_else(|| "audio".to_string()),
|
||||||
|
download_source: row
|
||||||
|
.get::<_, Option<String>>(24)?
|
||||||
|
.unwrap_or_else(|| "user".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,7 +1783,10 @@ pub async fn get_download_storage_stats(
|
|||||||
/// Delete all downloads for a user
|
/// Delete all downloads for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -1560,7 +1884,10 @@ pub async fn delete_album_downloads(
|
|||||||
"SELECT d.file_path FROM downloads d
|
"SELECT d.file_path FROM downloads d
|
||||||
JOIN items i ON d.item_id = i.id
|
JOIN items i ON d.item_id = i.id
|
||||||
WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
|
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
|
let file_paths: Vec<String> = db_service
|
||||||
@@ -1588,6 +1915,76 @@ pub async fn delete_album_downloads(
|
|||||||
Ok(deleted_count as i64)
|
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
|
/// Download manager statistics
|
||||||
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct DownloadManagerStats {
|
pub struct DownloadManagerStats {
|
||||||
@@ -1627,7 +2024,6 @@ pub async fn set_max_concurrent_downloads(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -1796,7 +2192,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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]
|
#[test]
|
||||||
@@ -2031,7 +2430,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let status: String = conn
|
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();
|
.unwrap();
|
||||||
assert_eq!(status, "downloading");
|
assert_eq!(status, "downloading");
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-044 | DR-056
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
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
|
/// Check if an item is pinned
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! Smart-cache statistics/config and album recommendation commands.
|
//! Smart-cache statistics/config and album recommendation commands.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-045 | DR-057
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use log::info;
|
use log::info;
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
||||||
// DR-015, DR-017, DR-021, DR-028
|
// DR-015, DR-017, DR-021, DR-028
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod catalog;
|
||||||
pub mod connectivity;
|
pub mod connectivity;
|
||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
@@ -17,17 +18,18 @@ pub mod storage;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
|
pub use catalog::*;
|
||||||
pub use connectivity::*;
|
pub use connectivity::*;
|
||||||
pub use conversions::*;
|
pub use conversions::*;
|
||||||
pub use device::*;
|
pub use device::*;
|
||||||
pub use download::*;
|
pub use download::*;
|
||||||
pub use offline::*;
|
pub use offline::*;
|
||||||
pub use playback_mode::*;
|
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 playback_reporting::*;
|
||||||
pub use player::*;
|
pub use player::*;
|
||||||
pub use playlist::*;
|
pub use playlist::*;
|
||||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
|
||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
pub use storage::*;
|
||||||
pub use sync::*;
|
pub use sync::*;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
//! Playback-mode transfer commands (local ↔ remote).
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-010 | DR-059
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
@@ -105,21 +109,30 @@ pub async fn playback_mode_get_remote_status(
|
|||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
let client_arc = controller.jellyfin_client();
|
let client_arc = controller.jellyfin_client();
|
||||||
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
|
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
|
// Get session info
|
||||||
match client.get_session(&session_id).await {
|
match client.get_session(&session_id).await {
|
||||||
Ok(Some(session)) => {
|
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)
|
.and_then(|ps| ps.position_ticks)
|
||||||
.unwrap_or(0);
|
.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)
|
.and_then(|item| item.run_time_ticks)
|
||||||
.unwrap_or(0);
|
.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)
|
.and_then(|ps| ps.is_paused)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
@@ -224,17 +237,20 @@ mod tests {
|
|||||||
fn test_playback_mode_deserialization_from_frontend() {
|
fn test_playback_mode_deserialization_from_frontend() {
|
||||||
// Test what frontend sends for Idle mode
|
// Test what frontend sends for Idle mode
|
||||||
let idle_json = r#"{"type":"idle"}"#;
|
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);
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
// Test what frontend sends for Local mode
|
// Test what frontend sends for Local mode
|
||||||
let local_json = r#"{"type":"local"}"#;
|
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);
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
// Test what frontend sends for Remote mode
|
// Test what frontend sends for Remote mode
|
||||||
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
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 {
|
match mode {
|
||||||
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||||
_ => panic!("Expected Remote mode"),
|
_ => panic!("Expected Remote mode"),
|
||||||
@@ -247,8 +263,8 @@ mod tests {
|
|||||||
|
|
||||||
// Test Search context (the recently fixed issue)
|
// Test Search context (the recently fixed issue)
|
||||||
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||||
let context: PlayTracksContext = serde_json::from_str(search_json)
|
let context: PlayTracksContext =
|
||||||
.expect("Failed to deserialize search context");
|
serde_json::from_str(search_json).expect("Failed to deserialize search context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Search { search_query } => {
|
PlayTracksContext::Search { search_query } => {
|
||||||
assert_eq!(search_query, "test query");
|
assert_eq!(search_query, "test query");
|
||||||
@@ -257,11 +273,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test Playlist context
|
// Test Playlist context
|
||||||
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
let playlist_json =
|
||||||
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||||
.expect("Failed to deserialize playlist context");
|
let context: PlayTracksContext =
|
||||||
|
serde_json::from_str(playlist_json).expect("Failed to deserialize playlist context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
PlayTracksContext::Playlist {
|
||||||
|
playlist_id,
|
||||||
|
playlist_name,
|
||||||
|
} => {
|
||||||
assert_eq!(playlist_id, "pl-123");
|
assert_eq!(playlist_id, "pl-123");
|
||||||
assert_eq!(playlist_name, "My Playlist");
|
assert_eq!(playlist_name, "My Playlist");
|
||||||
}
|
}
|
||||||
@@ -270,8 +290,8 @@ mod tests {
|
|||||||
|
|
||||||
// Test Custom context
|
// Test Custom context
|
||||||
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||||
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
let context: PlayTracksContext =
|
||||||
.expect("Failed to deserialize custom context");
|
serde_json::from_str(custom_json).expect("Failed to deserialize custom context");
|
||||||
match context {
|
match context {
|
||||||
PlayTracksContext::Custom { label } => {
|
PlayTracksContext::Custom { label } => {
|
||||||
assert_eq!(label, Some("Custom Queue".to_string()));
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Tauri commands for playback reporting operations
|
//! 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,
|
//! These commands provide frontend access to the Rust playback reporting system,
|
||||||
//! replacing the TypeScript implementation with native Rust reporting.
|
//! replacing the TypeScript implementation with native Rust reporting.
|
||||||
//!
|
//!
|
||||||
@@ -16,7 +18,7 @@ use crate::commands::connectivity::ConnectivityMonitorWrapper;
|
|||||||
use crate::commands::storage::DatabaseWrapper;
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
use crate::jellyfin::client::JellyfinClient;
|
use crate::jellyfin::client::JellyfinClient;
|
||||||
use crate::jellyfin::JellyfinConfig;
|
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;
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
|
|
||||||
/// Tauri state wrapper for PlaybackReporter
|
/// Tauri state wrapper for PlaybackReporter
|
||||||
@@ -61,7 +63,10 @@ pub async fn playback_reporter_init(
|
|||||||
// Store in wrapper
|
// Store in wrapper
|
||||||
*reporter_wrapper.0.lock().await = Some(reporter);
|
*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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +210,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Verify enum variant can be created and pattern matched
|
// 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!(item_id, "item-123");
|
||||||
assert_eq!(position_ticks, 15_000_000);
|
assert_eq!(position_ticks, 15_000_000);
|
||||||
assert!(context.is_some());
|
assert!(context.is_some());
|
||||||
@@ -225,7 +235,10 @@ mod tests {
|
|||||||
context: None,
|
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_eq!(item_id, "item-789");
|
||||||
assert!(context.is_none());
|
assert!(context.is_none());
|
||||||
} else {
|
} else {
|
||||||
@@ -241,7 +254,12 @@ mod tests {
|
|||||||
is_paused: true,
|
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!(item_id, "item-999");
|
||||||
assert_eq!(position_ticks, 30_000_000);
|
assert_eq!(position_ticks, 30_000_000);
|
||||||
assert!(is_paused);
|
assert!(is_paused);
|
||||||
@@ -272,7 +290,11 @@ mod tests {
|
|||||||
position_ticks: 120_000_000,
|
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!(item_id, "item-111");
|
||||||
assert_eq!(position_ticks, 120_000_000);
|
assert_eq!(position_ticks, 120_000_000);
|
||||||
} else {
|
} else {
|
||||||
@@ -364,7 +386,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cloned = operation.clone();
|
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_eq!(item_id, "item-clone");
|
||||||
assert!(is_paused);
|
assert!(is_paused);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Queue manipulation commands (add / remove / move / skip).
|
//! Queue manipulation commands (add / remove / move / skip).
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-015 | DR-005, DR-020
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -150,16 +152,25 @@ pub async fn player_add_track_by_id(
|
|||||||
) -> Result<QueueStatus, String> {
|
) -> Result<QueueStatus, String> {
|
||||||
use crate::player::queue::AddPosition;
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
info!("player_add_track_by_id called: track_id={}, position={}",
|
info!(
|
||||||
request.track_id, request.position);
|
"player_add_track_by_id called: track_id={}, position={}",
|
||||||
|
request.track_id, request.position
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository (hybrid - supports offline/online)
|
// 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")?;
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
// Fetch track metadata via repository
|
// Fetch track metadata via repository
|
||||||
info!("Fetching metadata for track {} via repository", request.track_id);
|
info!(
|
||||||
let track = repository.get_item(&request.track_id).await
|
"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))?;
|
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||||
|
|
||||||
// Check for local download first
|
// Check for local download first
|
||||||
@@ -173,7 +184,9 @@ pub async fn player_add_track_by_id(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get stream URL from repository (works online/offline)
|
// 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))?;
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
MediaSource::Remote {
|
MediaSource::Remote {
|
||||||
@@ -188,23 +201,31 @@ pub async fn player_add_track_by_id(
|
|||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
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: track.album_name.clone(),
|
||||||
album_name: track.album_name.clone(), // Frontend compatibility
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
album_id: track.album_id.clone(),
|
album_id: track.album_id.clone(),
|
||||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
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
|
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
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
playlist_id: None,
|
playlist_id: None,
|
||||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
track.album_id.as_ref().map(|album_id| {
|
track.album_id.as_ref().map(|album_id| {
|
||||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
repository.get_image_url(
|
||||||
max_width: Some(300),
|
album_id,
|
||||||
tag: Some(tag),
|
ImageType::Primary,
|
||||||
..Default::default()
|
Some(ImageOptions {
|
||||||
}))
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -250,18 +271,25 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
) -> Result<QueueStatus, String> {
|
) -> Result<QueueStatus, String> {
|
||||||
use crate::player::queue::AddPosition;
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
info!(
|
||||||
request.track_ids.len(), request.position);
|
"player_add_tracks_by_ids called: {} tracks, position={}",
|
||||||
|
request.track_ids.len(),
|
||||||
|
request.position
|
||||||
|
);
|
||||||
|
|
||||||
// Get repository (hybrid - supports offline/online)
|
// 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")?;
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
// Fetch metadata and build MediaItems for all tracks
|
// Fetch metadata and build MediaItems for all tracks
|
||||||
let mut media_items = Vec::new();
|
let mut media_items = Vec::new();
|
||||||
for track_id in &request.track_ids {
|
for track_id in &request.track_ids {
|
||||||
info!("Fetching metadata for track {} via repository", track_id);
|
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))?;
|
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||||
|
|
||||||
// Check for local download first
|
// Check for local download first
|
||||||
@@ -275,7 +303,9 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get stream URL from repository (works online/offline)
|
// 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))?;
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
MediaSource::Remote {
|
MediaSource::Remote {
|
||||||
@@ -290,23 +320,31 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
id: track.id.clone(),
|
id: track.id.clone(),
|
||||||
title: track.name.clone(),
|
title: track.name.clone(),
|
||||||
name: Some(track.name.clone()), // Frontend compatibility
|
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: track.album_name.clone(),
|
||||||
album_name: track.album_name.clone(), // Frontend compatibility
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
album_id: track.album_id.clone(),
|
album_id: track.album_id.clone(),
|
||||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
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
|
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
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
playlist_id: None,
|
playlist_id: None,
|
||||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
track.album_id.as_ref().map(|album_id| {
|
track.album_id.as_ref().map(|album_id| {
|
||||||
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
repository.get_image_url(
|
||||||
max_width: Some(300),
|
album_id,
|
||||||
tag: Some(tag),
|
ImageType::Primary,
|
||||||
..Default::default()
|
Some(ImageOptions {
|
||||||
}))
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
media_type: MediaType::Audio,
|
media_type: MediaType::Audio,
|
||||||
@@ -339,7 +377,10 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
drop(queue_lock);
|
drop(queue_lock);
|
||||||
controller.emit_queue_changed();
|
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)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Remote Jellyfin session control commands (casting to another device).
|
//! 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
|
//! These thin command adapters forward control actions to the active Jellyfin
|
||||||
//! session via the player's configured `JellyfinClient`.
|
//! session via the player's configured `JellyfinClient`.
|
||||||
|
|
||||||
@@ -17,22 +19,36 @@ pub async fn remote_play_on_session(
|
|||||||
item_ids: Vec<String>,
|
item_ids: Vec<String>,
|
||||||
start_index: usize,
|
start_index: usize,
|
||||||
) -> Result<(), String> {
|
) -> 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);
|
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
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");
|
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
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,
|
session_id: String,
|
||||||
command: String,
|
command: String,
|
||||||
) -> Result<(), 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 client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -68,11 +92,19 @@ pub async fn remote_session_seek(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
position_ticks: i64,
|
position_ticks: i64,
|
||||||
) -> Result<(), String> {
|
) -> 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 client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -92,11 +124,19 @@ pub async fn remote_session_set_volume(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
volume: i32,
|
volume: i32,
|
||||||
) -> Result<(), String> {
|
) -> 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 client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -119,7 +159,11 @@ pub async fn remote_session_toggle_mute(
|
|||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -145,7 +189,11 @@ pub async fn lms_get_sync_groups(
|
|||||||
) -> Result<Vec<LmsSyncGroup>, String> {
|
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -164,11 +212,19 @@ pub async fn lms_create_sync_group(
|
|||||||
master_mac: String,
|
master_mac: String,
|
||||||
slave_macs: Vec<String>,
|
slave_macs: Vec<String>,
|
||||||
) -> Result<(), 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 client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -189,7 +245,11 @@ pub async fn lms_unsync_player(
|
|||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
@@ -210,7 +270,11 @@ pub async fn lms_dissolve_sync_group(
|
|||||||
|
|
||||||
let client_opt = {
|
let client_opt = {
|
||||||
let controller = player.0.lock().await;
|
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 {
|
if let Some(client) = client_opt {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Media session state commands.
|
//! Media session state commands.
|
||||||
//!
|
//!
|
||||||
|
//! TRACES: UR-005 | DR-009
|
||||||
|
//!
|
||||||
//! Read and dismiss the current media session (the Now Playing surface backing
|
//! Read and dismiss the current media session (the Now Playing surface backing
|
||||||
//! lockscreen/notification controls).
|
//! lockscreen/notification controls).
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
//! Audio and video playback settings commands.
|
//! 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 tauri::State;
|
||||||
|
|
||||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||||
use crate::player::AutoplaySettings;
|
use crate::player::AutoplaySettings;
|
||||||
use crate::settings::{AudioSettings, VideoSettings};
|
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -12,13 +14,32 @@ pub async fn player_set_audio_settings(
|
|||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
settings: AudioSettings,
|
settings: AudioSettings,
|
||||||
) -> Result<AudioSettings, String> {
|
) -> 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;
|
let mut controller = player.0.lock().await;
|
||||||
controller
|
controller
|
||||||
.set_audio_settings(&settings)
|
.set_audio_settings(&validated)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(controller.audio_settings())
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_get_audio_settings(
|
pub async fn player_get_audio_settings(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Sleep-timer and autoplay commands.
|
//! 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
|
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
|
||||||
//! logic, plus persistence of autoplay settings to the database.
|
//! logic, plus persistence of autoplay settings to the database.
|
||||||
|
|
||||||
@@ -7,8 +9,8 @@ use std::sync::Arc;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
|
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStateWrapper,
|
||||||
PlayerStateWrapper,
|
PlayerStatus,
|
||||||
};
|
};
|
||||||
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
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 media_item = create_media_item(item, Some(&db)).await?;
|
||||||
|
|
||||||
let controller = player.0.lock().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))
|
Ok(get_player_status(&controller))
|
||||||
}
|
}
|
||||||
@@ -164,7 +168,10 @@ pub async fn player_on_playback_ended(
|
|||||||
if let Some(repo) = repo {
|
if let Some(repo) = repo {
|
||||||
controller.on_video_playback_ended(id, repo).await?
|
controller.on_video_playback_ended(id, repo).await?
|
||||||
} else {
|
} 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
|
AutoplayDecision::Stop
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
use log::debug;
|
use log::debug;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::repository::{MediaRepository, types::*};
|
|
||||||
use super::repository::RepositoryManagerWrapper;
|
use super::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::repository::{types::*, MediaRepository};
|
||||||
|
|
||||||
/// Create a new playlist
|
/// Create a new playlist
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -21,7 +21,8 @@ pub async fn playlist_create(
|
|||||||
debug!("[PLAYLIST] create called: name={}", name);
|
debug!("[PLAYLIST] create called: name={}", name);
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
let ids = item_ids.unwrap_or_default();
|
let ids = item_ids.unwrap_or_default();
|
||||||
repo.as_ref().create_playlist(&name, &ids)
|
repo.as_ref()
|
||||||
|
.create_playlist(&name, &ids)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -36,7 +37,8 @@ pub async fn playlist_delete(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -50,9 +52,13 @@ pub async fn playlist_rename(
|
|||||||
playlist_id: String,
|
playlist_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<(), 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")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -67,7 +73,8 @@ pub async fn playlist_get_items(
|
|||||||
) -> Result<Vec<PlaylistEntry>, String> {
|
) -> Result<Vec<PlaylistEntry>, String> {
|
||||||
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -81,9 +88,14 @@ pub async fn playlist_add_items(
|
|||||||
playlist_id: String,
|
playlist_id: String,
|
||||||
item_ids: Vec<String>,
|
item_ids: Vec<String>,
|
||||||
) -> Result<(), 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")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -97,9 +109,14 @@ pub async fn playlist_remove_items(
|
|||||||
playlist_id: String,
|
playlist_id: String,
|
||||||
entry_ids: Vec<String>,
|
entry_ids: Vec<String>,
|
||||||
) -> Result<(), 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")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -114,9 +131,13 @@ pub async fn playlist_move_item(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
new_index: u32,
|
new_index: u32,
|
||||||
) -> Result<(), String> {
|
) -> 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")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
|
use crate::repository::{
|
||||||
|
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
|
||||||
|
};
|
||||||
|
|
||||||
/// Repository handle manager
|
/// Repository handle manager
|
||||||
pub struct RepositoryManager {
|
pub struct RepositoryManager {
|
||||||
@@ -81,8 +83,13 @@ pub async fn repository_create(
|
|||||||
|
|
||||||
// Create online repository wired to connectivity reporting
|
// Create online repository wired to connectivity reporting
|
||||||
debug!("[REPO] Creating online repository...");
|
debug!("[REPO] Creating online repository...");
|
||||||
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token)
|
let online = OnlineRepository::new(
|
||||||
.with_connectivity(connectivity_reporter);
|
Arc::new(http_client),
|
||||||
|
server_url,
|
||||||
|
user_id.clone(),
|
||||||
|
access_token,
|
||||||
|
)
|
||||||
|
.with_connectivity(connectivity_reporter);
|
||||||
debug!("[REPO] Online repository created");
|
debug!("[REPO] Online repository created");
|
||||||
|
|
||||||
// Create offline repository with async-safe database service
|
// Create offline repository with async-safe database service
|
||||||
@@ -151,12 +158,10 @@ pub async fn repository_get_libraries(
|
|||||||
"Repository not found".to_string()
|
"Repository not found".to_string()
|
||||||
})?;
|
})?;
|
||||||
debug!("[REPO] Repository found, fetching libraries...");
|
debug!("[REPO] Repository found, fetching libraries...");
|
||||||
repo.as_ref().get_libraries()
|
repo.as_ref().get_libraries().await.map_err(|e| {
|
||||||
.await
|
error!("[REPO] Error fetching libraries: {:?}", e);
|
||||||
.map_err(|e| {
|
format!("{:?}", e)
|
||||||
error!("[REPO] Error fetching libraries: {:?}", e);
|
})
|
||||||
format!("{:?}", e)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get items in a container (library, folder, album, etc.)
|
/// Get items in a container (library, folder, album, etc.)
|
||||||
@@ -169,7 +174,8 @@ pub async fn repository_get_items(
|
|||||||
options: Option<GetItemsOptions>,
|
options: Option<GetItemsOptions>,
|
||||||
) -> Result<SearchResult, String> {
|
) -> Result<SearchResult, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -183,7 +189,58 @@ pub async fn repository_get_item(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
) -> Result<MediaItem, String> {
|
) -> Result<MediaItem, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -200,7 +257,8 @@ pub async fn repository_jray_actors_at(
|
|||||||
t: f64,
|
t: f64,
|
||||||
) -> Result<Vec<crate::repository::JRayActor>, String> {
|
) -> Result<Vec<crate::repository::JRayActor>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -215,7 +273,8 @@ pub async fn repository_get_latest_items(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, String> {
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -235,7 +294,8 @@ pub async fn repository_get_resume_items(
|
|||||||
"Repository not found".to_string()
|
"Repository not found".to_string()
|
||||||
})?;
|
})?;
|
||||||
debug!("[REPO] Repository found, fetching resume items...");
|
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
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
error!("[REPO] Error fetching resume items: {:?}", e);
|
error!("[REPO] Error fetching resume items: {:?}", e);
|
||||||
@@ -253,7 +313,8 @@ pub async fn repository_get_next_up_episodes(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, String> {
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -267,7 +328,8 @@ pub async fn repository_get_recently_played_audio(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, String> {
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -281,7 +343,8 @@ pub async fn repository_get_resume_movies(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, String> {
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -296,7 +359,8 @@ pub async fn repository_get_rediscover_albums(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, String> {
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -310,7 +374,8 @@ pub async fn repository_get_genres(
|
|||||||
parent_id: Option<String>,
|
parent_id: Option<String>,
|
||||||
) -> Result<Vec<Genre>, String> {
|
) -> Result<Vec<Genre>, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -363,8 +428,7 @@ pub async fn repository_search(
|
|||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match repo_bg.search_server_only(&query, options).await {
|
match repo_bg.search_server_only(&query, options).await {
|
||||||
Ok(server_result) => {
|
Ok(server_result) => {
|
||||||
let merged =
|
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||||
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
|
||||||
let event = SearchUpdateEvent {
|
let event = SearchUpdateEvent {
|
||||||
request_id,
|
request_id,
|
||||||
result: merged,
|
result: merged,
|
||||||
@@ -376,7 +440,10 @@ pub async fn repository_search(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Server failed — the cache results are already on screen, so
|
// Server failed — the cache results are already on screen, so
|
||||||
// just log. (Offline / unreachable server falls here.)
|
// 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,
|
item_id: String,
|
||||||
) -> Result<PlaybackInfo, String> {
|
) -> Result<PlaybackInfo, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -421,6 +489,31 @@ pub async fn repository_get_video_stream_url(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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
|
/// Get audio stream URL for a track
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -486,10 +579,12 @@ pub async fn repository_report_playback_start(
|
|||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
position_ticks: i64,
|
position_ms: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let position_ticks = position_ms * 10_000;
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -501,10 +596,12 @@ pub async fn repository_report_playback_progress(
|
|||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
position_ticks: i64,
|
position_ms: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
let position_ticks = position_ms * 10_000;
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -516,10 +613,13 @@ pub async fn repository_report_playback_stopped(
|
|||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
position_ticks: i64,
|
position_ms: i64,
|
||||||
) -> Result<(), String> {
|
) -> 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")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -551,7 +651,9 @@ pub fn repository_get_subtitle_url(
|
|||||||
format: String,
|
format: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
/// Get video download URL with quality preset
|
||||||
@@ -566,7 +668,9 @@ pub fn repository_get_video_download_url(
|
|||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
/// Mark an item as favorite
|
||||||
@@ -578,7 +682,8 @@ pub async fn repository_mark_favorite(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -592,7 +697,8 @@ pub async fn repository_unmark_favorite(
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -606,7 +712,8 @@ pub async fn repository_get_person(
|
|||||||
person_id: String,
|
person_id: String,
|
||||||
) -> Result<MediaItem, String> {
|
) -> Result<MediaItem, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -621,7 +728,8 @@ pub async fn repository_get_items_by_person(
|
|||||||
options: Option<GetItemsOptions>,
|
options: Option<GetItemsOptions>,
|
||||||
) -> Result<SearchResult, String> {
|
) -> Result<SearchResult, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
@@ -636,7 +744,8 @@ pub async fn repository_get_similar_items(
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<SearchResult, String> {
|
) -> Result<SearchResult, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! TRACES: UR-010 | JA-021 | DR-037
|
//! TRACES: UR-010 | JA-021 | DR-037
|
||||||
|
|
||||||
|
use crate::jellyfin::client::SessionInfo;
|
||||||
|
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
|
||||||
use crate::jellyfin::client::SessionInfo;
|
|
||||||
|
|
||||||
/// Tauri state wrapper for SessionPollerManager
|
/// Tauri state wrapper for SessionPollerManager
|
||||||
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Tauri commands for database/storage operations
|
//! 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};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -7,8 +9,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::credentials::CredentialStore;
|
use crate::credentials::CredentialStore;
|
||||||
use crate::storage::Database;
|
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
use crate::storage::Database;
|
||||||
use crate::thumbnail::ThumbnailCache;
|
use crate::thumbnail::ThumbnailCache;
|
||||||
|
|
||||||
use super::SmartCacheWrapper;
|
use super::SmartCacheWrapper;
|
||||||
@@ -86,7 +88,8 @@ pub fn storage_get_path(db: State<DatabaseWrapper>) -> Result<String, String> {
|
|||||||
let db_path = database.path();
|
let db_path = database.path();
|
||||||
|
|
||||||
// Return the parent directory instead of the database file 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_or_else(|| "Database path has no parent directory".to_string())?;
|
||||||
|
|
||||||
Ok(storage_dir.to_string_lossy().to_string())
|
Ok(storage_dir.to_string_lossy().to_string())
|
||||||
@@ -160,13 +163,16 @@ pub async fn storage_save_server(
|
|||||||
/// Get all saved servers
|
/// Get all saved servers
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
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
|
let servers = db_service
|
||||||
.query_many(query, |row| {
|
.query_many(query, |row| {
|
||||||
@@ -221,7 +227,10 @@ pub async fn storage_delete_server(
|
|||||||
vec![QueryParam::String(server_id)],
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -237,7 +246,10 @@ pub async fn storage_save_user(
|
|||||||
username: String,
|
username: String,
|
||||||
access_token: Option<String>,
|
access_token: Option<String>,
|
||||||
) -> Result<bool, 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 (db_service, db_path) = {
|
||||||
let database = db.0.lock().map_err(|e| {
|
let database = db.0.lock().map_err(|e| {
|
||||||
@@ -277,7 +289,10 @@ pub async fn storage_save_user(
|
|||||||
"SELECT COUNT(*) FROM users WHERE id = ?",
|
"SELECT COUNT(*) FROM users WHERE id = ?",
|
||||||
vec![QueryParam::String(id.clone())],
|
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!("VERIFY: {} users with id={} after insert", verify_count, id);
|
||||||
debug!("Database path: {:?}", db_path);
|
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)
|
// 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");
|
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
|
// Activate the specified user and update last_login_at
|
||||||
let activate_query = Query::with_params(
|
let activate_query = Query::with_params(
|
||||||
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"UPDATE users SET is_active = 1, last_login_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
vec![QueryParam::String(user_id.clone())],
|
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);
|
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
|
// Verify the user is now active
|
||||||
let verify_query = Query::new("SELECT COUNT(*) FROM users WHERE is_active = 1");
|
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!("VERIFY: {} active users after set_active", verify_count);
|
||||||
debug!("Database path: {:?}", db_path);
|
debug!("Database path: {:?}", db_path);
|
||||||
|
|
||||||
@@ -434,12 +458,21 @@ pub async fn storage_get_active_session(
|
|||||||
|
|
||||||
// Debug: count total users and active users
|
// Debug: count total users and active users
|
||||||
let total_query = Query::new("SELECT COUNT(*) FROM 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_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);
|
debug!("Database path: {:?}", db_path);
|
||||||
|
|
||||||
// Find active user with their server info, ordered by most recently logged in
|
// 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
|
JOIN servers s ON u.server_id = s.id
|
||||||
WHERE u.is_active = 1
|
WHERE u.is_active = 1
|
||||||
ORDER BY u.last_login_at DESC
|
ORDER BY u.last_login_at DESC
|
||||||
LIMIT 1"
|
LIMIT 1",
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = db_service.query_optional(session_query, |row| {
|
let result = db_service
|
||||||
Ok((
|
.query_optional(session_query, |row| {
|
||||||
row.get::<_, String>(0)?,
|
Ok((
|
||||||
row.get::<_, String>(1)?,
|
row.get::<_, String>(0)?,
|
||||||
row.get::<_, String>(2)?,
|
row.get::<_, String>(1)?,
|
||||||
row.get::<_, String>(3)?,
|
row.get::<_, String>(2)?,
|
||||||
row.get::<_, String>(4)?,
|
row.get::<_, String>(3)?,
|
||||||
))
|
row.get::<_, String>(4)?,
|
||||||
}).await.map_err(|e| e.to_string())?;
|
))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some((user_id, username, server_id, server_url, server_name)) => {
|
Some((user_id, username, server_id, server_url, server_name)) => {
|
||||||
@@ -478,7 +514,7 @@ pub async fn storage_get_active_session(
|
|||||||
server_name,
|
server_name,
|
||||||
access_token,
|
access_token,
|
||||||
}))
|
}))
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Token not found or error - session is invalid
|
// Token not found or error - session is invalid
|
||||||
warn!("Failed to get token from secure storage: {:?}", e);
|
warn!("Failed to get token from secure storage: {:?}", e);
|
||||||
@@ -547,7 +583,9 @@ pub async fn storage_delete_user(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaybackProgress {
|
pub struct PlaybackProgress {
|
||||||
pub item_id: String,
|
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_played: bool,
|
||||||
pub is_favorite: bool,
|
pub is_favorite: bool,
|
||||||
pub play_count: i32,
|
pub play_count: i32,
|
||||||
@@ -561,8 +599,11 @@ pub async fn storage_update_playback_progress(
|
|||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
position_ticks: i64,
|
position_ms: i64,
|
||||||
) -> Result<(), String> {
|
) -> 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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -613,12 +654,14 @@ pub async fn storage_update_playback_context(
|
|||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
position_ticks: i64,
|
position_ms: i64,
|
||||||
context_type: Option<String>,
|
context_type: Option<String>,
|
||||||
context_id: Option<String>,
|
context_id: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::storage::db_service::{Query, QueryParam};
|
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 db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -638,8 +681,12 @@ pub async fn storage_update_playback_context(
|
|||||||
QueryParam::String(user_id.clone()),
|
QueryParam::String(user_id.clone()),
|
||||||
QueryParam::String(item_id.clone()),
|
QueryParam::String(item_id.clone()),
|
||||||
QueryParam::Int64(position_ticks),
|
QueryParam::Int64(position_ticks),
|
||||||
context_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
context_type
|
||||||
context_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.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() {
|
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
|
// Queue each track with high priority (50) and mark as auto-downloaded
|
||||||
for (track_id, track_name, artist_name, album_name) in tracks {
|
for (track_id, track_name, artist_name, album_name) in tracks {
|
||||||
// Generate a sanitized file path (simplified version)
|
// Generate a sanitized file path (simplified version)
|
||||||
let sanitized_name = track_name
|
let sanitized_name = track_name
|
||||||
.chars()
|
.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>();
|
.collect::<String>();
|
||||||
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
|
let file_path = format!("downloads/{}/{}.mp3", album_id, sanitized_name);
|
||||||
|
|
||||||
@@ -808,9 +864,10 @@ pub async fn storage_get_playback_progress(
|
|||||||
|
|
||||||
db_service
|
db_service
|
||||||
.query_optional(query, |row| {
|
.query_optional(query, |row| {
|
||||||
|
let position_ticks: i64 = row.get(1)?;
|
||||||
Ok(PlaybackProgress {
|
Ok(PlaybackProgress {
|
||||||
item_id: row.get(0)?,
|
item_id: row.get(0)?,
|
||||||
position_ticks: row.get(1)?,
|
position_ms: position_ticks / 10_000,
|
||||||
is_played: row.get::<_, i32>(2)? != 0,
|
is_played: row.get::<_, i32>(2)? != 0,
|
||||||
is_favorite: row.get::<_, i32>(3)? != 0,
|
is_favorite: row.get::<_, i32>(3)? != 0,
|
||||||
play_count: row.get(4)?,
|
play_count: row.get(4)?,
|
||||||
@@ -1123,7 +1180,10 @@ pub async fn storage_search_items(
|
|||||||
limit_clause
|
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
|
let items = db_service
|
||||||
.query_many(query_obj, row_to_cached_item)
|
.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.)
|
// Generate sort_name from name (remove leading "The ", "A ", etc.)
|
||||||
let sort_name = item.name
|
let sort_name = item
|
||||||
|
.name
|
||||||
.strip_prefix("The ")
|
.strip_prefix("The ")
|
||||||
.or_else(|| item.name.strip_prefix("A "))
|
.or_else(|| item.name.strip_prefix("A "))
|
||||||
.or_else(|| item.name.strip_prefix("An "))
|
.or_else(|| item.name.strip_prefix("An "))
|
||||||
@@ -1202,28 +1263,66 @@ pub async fn storage_save_item(
|
|||||||
vec![
|
vec![
|
||||||
QueryParam::String(item.id),
|
QueryParam::String(item.id),
|
||||||
QueryParam::String(server_id),
|
QueryParam::String(server_id),
|
||||||
item.library_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.library_id
|
||||||
item.parent_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
item.parent_id
|
||||||
|
.map(QueryParam::String)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
QueryParam::String(item.name),
|
QueryParam::String(item.name),
|
||||||
QueryParam::String(sort_name),
|
QueryParam::String(sort_name),
|
||||||
QueryParam::String(item.item_type),
|
QueryParam::String(item.item_type),
|
||||||
item.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.overview
|
||||||
item.genres.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::String)
|
||||||
item.runtime_ticks.map(QueryParam::Int64).unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
item.production_year.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
item.genres
|
||||||
item.community_rating.map(QueryParam::Float).unwrap_or(QueryParam::Null),
|
.map(QueryParam::String)
|
||||||
item.official_rating.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
item.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.runtime_ticks
|
||||||
item.album_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::Int64)
|
||||||
item.album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
item.album_artist.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.production_year
|
||||||
item.artists.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::Int)
|
||||||
item.index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
item.series_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.community_rating
|
||||||
item.series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::Float)
|
||||||
item.season_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.unwrap_or(QueryParam::Null),
|
||||||
item.season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.official_rating
|
||||||
item.parent_index_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
|
.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)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1405,7 +1503,7 @@ mod tests {
|
|||||||
fn test_playback_progress_serialization() {
|
fn test_playback_progress_serialization() {
|
||||||
let progress = PlaybackProgress {
|
let progress = PlaybackProgress {
|
||||||
item_id: "item-123".to_string(),
|
item_id: "item-123".to_string(),
|
||||||
position_ticks: 150_000_000,
|
position_ms: 150_000_000,
|
||||||
is_played: true,
|
is_played: true,
|
||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
play_count: 3,
|
play_count: 3,
|
||||||
@@ -1422,7 +1520,7 @@ mod tests {
|
|||||||
fn test_playback_progress_played_status() {
|
fn test_playback_progress_played_status() {
|
||||||
let progress = PlaybackProgress {
|
let progress = PlaybackProgress {
|
||||||
item_id: "item-456".to_string(),
|
item_id: "item-456".to_string(),
|
||||||
position_ticks: 0,
|
position_ms: 0,
|
||||||
is_played: true,
|
is_played: true,
|
||||||
is_favorite: true,
|
is_favorite: true,
|
||||||
play_count: 1,
|
play_count: 1,
|
||||||
@@ -1440,7 +1538,7 @@ mod tests {
|
|||||||
fn test_playback_progress_not_played() {
|
fn test_playback_progress_not_played() {
|
||||||
let progress = PlaybackProgress {
|
let progress = PlaybackProgress {
|
||||||
item_id: "item-789".to_string(),
|
item_id: "item-789".to_string(),
|
||||||
position_ticks: 30_000_000,
|
position_ms: 30_000_000,
|
||||||
is_played: false,
|
is_played: false,
|
||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
play_count: 0,
|
play_count: 0,
|
||||||
@@ -1510,7 +1608,7 @@ mod tests {
|
|||||||
fn test_playback_progress_camel_case() {
|
fn test_playback_progress_camel_case() {
|
||||||
let progress = PlaybackProgress {
|
let progress = PlaybackProgress {
|
||||||
item_id: "i1".to_string(),
|
item_id: "i1".to_string(),
|
||||||
position_ticks: 100,
|
position_ms: 100,
|
||||||
is_played: true,
|
is_played: true,
|
||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
play_count: 1,
|
play_count: 1,
|
||||||
@@ -1519,7 +1617,7 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&progress).unwrap();
|
let json = serde_json::to_string(&progress).unwrap();
|
||||||
// Verify camelCase serialization
|
// Verify camelCase serialization
|
||||||
assert!(json.contains("itemId"));
|
assert!(json.contains("itemId"));
|
||||||
assert!(json.contains("positionTicks"));
|
assert!(json.contains("positionMs"));
|
||||||
assert!(json.contains("isPlayed"));
|
assert!(json.contains("isPlayed"));
|
||||||
assert!(json.contains("isFavorite"));
|
assert!(json.contains("isFavorite"));
|
||||||
assert!(json.contains("playCount"));
|
assert!(json.contains("playCount"));
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
//! Person/cast metadata cache commands.
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::DatabaseWrapper;
|
use super::DatabaseWrapper;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
|
||||||
/// Cached person info returned to frontend
|
/// Cached person info returned to frontend
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -54,10 +55,22 @@ pub async fn storage_save_person(
|
|||||||
QueryParam::String(person.id),
|
QueryParam::String(person.id),
|
||||||
QueryParam::String(person.server_id),
|
QueryParam::String(person.server_id),
|
||||||
QueryParam::String(person.name),
|
QueryParam::String(person.name),
|
||||||
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
person
|
||||||
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.overview
|
||||||
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.map(QueryParam::String)
|
||||||
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
.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();
|
let associations_clone = associations.clone();
|
||||||
|
|
||||||
// Use transaction for batch insert
|
// Use transaction for batch insert
|
||||||
db_service.transaction(move |tx| {
|
db_service
|
||||||
for assoc in &associations_clone {
|
.transaction(move |tx| {
|
||||||
let query = Query::with_params(
|
for assoc in &associations_clone {
|
||||||
"INSERT OR REPLACE INTO item_people (
|
let query = Query::with_params(
|
||||||
|
"INSERT OR REPLACE INTO item_people (
|
||||||
item_id, person_id, server_id, person_type, role, sort_order, synced_at
|
item_id, person_id, server_id, person_type, role, sort_order, synced_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(assoc.item_id.clone()),
|
QueryParam::String(assoc.item_id.clone()),
|
||||||
QueryParam::String(assoc.person_id.clone()),
|
QueryParam::String(assoc.person_id.clone()),
|
||||||
QueryParam::String(assoc.server_id.clone()),
|
QueryParam::String(assoc.server_id.clone()),
|
||||||
QueryParam::String(assoc.person_type.clone()),
|
QueryParam::String(assoc.person_type.clone()),
|
||||||
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
assoc
|
||||||
QueryParam::Int(assoc.sort_order),
|
.role
|
||||||
],
|
.clone()
|
||||||
);
|
.map(QueryParam::String)
|
||||||
tx.execute(query)?;
|
.unwrap_or(QueryParam::Null),
|
||||||
}
|
QueryParam::Int(assoc.sort_order),
|
||||||
Ok(())
|
],
|
||||||
}).await.map_err(|e| e.to_string())?;
|
);
|
||||||
|
tx.execute(query)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -176,4 +196,3 @@ pub async fn storage_get_item_people(
|
|||||||
|
|
||||||
Ok(people)
|
Ok(people)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
//! Per-series preferred audio track commands.
|
//! Per-series preferred audio track commands.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-021 | DR-024
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use super::DatabaseWrapper;
|
use super::DatabaseWrapper;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
|
||||||
/// Audio track preference for a series
|
/// Audio track preference for a series
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
//! Thumbnail cache and image-URL commands.
|
//! 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 serde::Deserialize;
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
||||||
use crate::commands::repository::RepositoryManagerWrapper;
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
@@ -11,7 +13,6 @@ use crate::repository::types::{ImageOptions, ImageType};
|
|||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
|
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
|
||||||
|
|
||||||
|
|
||||||
/// Get cached thumbnail path, returns None if not cached
|
/// Get cached thumbnail path, returns None if not cached
|
||||||
/// Also updates last_accessed timestamp for LRU tracking
|
/// Also updates last_accessed timestamp for LRU tracking
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -28,7 +29,8 @@ pub async fn thumbnail_get_cached(
|
|||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = thumbnail_cache.0
|
let result = thumbnail_cache
|
||||||
|
.0
|
||||||
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
||||||
.await
|
.await
|
||||||
.map(|p| p.to_string_lossy().to_string());
|
.map(|p| p.to_string_lossy().to_string());
|
||||||
@@ -61,7 +63,10 @@ pub async fn thumbnail_save(
|
|||||||
Arc::new(database.service())
|
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())
|
Ok(path.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +184,7 @@ pub async fn image_get_url(
|
|||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: GetImageRequest,
|
request: GetImageRequest,
|
||||||
) -> Result<String, String> {
|
) -> 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;
|
use std::fs;
|
||||||
|
|
||||||
let tag = request.tag.as_deref().unwrap_or("default");
|
let tag = request.tag.as_deref().unwrap_or("default");
|
||||||
@@ -191,14 +196,18 @@ pub async fn image_get_url(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
|
if let Some(cached_path) = thumbnail_cache
|
||||||
db_service.clone(),
|
.0
|
||||||
&request.item_id,
|
.get_cached_path(
|
||||||
&request.image_type,
|
db_service.clone(),
|
||||||
tag,
|
&request.item_id,
|
||||||
).await {
|
&request.image_type,
|
||||||
let image_data = fs::read(&cached_path)
|
tag,
|
||||||
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let image_data =
|
||||||
|
fs::read(&cached_path).map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||||
let base64_data = BASE64.encode(&image_data);
|
let base64_data = BASE64.encode(&image_data);
|
||||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||||
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
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.
|
// Not cached — fetch from server and cache.
|
||||||
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
// 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())?;
|
.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())?;
|
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||||
|
|
||||||
let image_type_enum = match request.image_type.as_str() {
|
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 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))?;
|
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||||
|
|
||||||
let cached_path = thumbnail_cache.0.save_thumbnail(
|
let cached_path = thumbnail_cache
|
||||||
db_service,
|
.0
|
||||||
&request.item_id,
|
.save_thumbnail(
|
||||||
&request.image_type,
|
db_service,
|
||||||
tag,
|
&request.item_id,
|
||||||
&image_data,
|
&request.image_type,
|
||||||
request.max_width.map(|w| w as i32),
|
tag,
|
||||||
request.max_height.map(|h| h as i32),
|
&image_data,
|
||||||
).await?;
|
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 base64_data = BASE64.encode(&image_data);
|
||||||
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
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())?;
|
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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
@@ -110,10 +113,7 @@ pub async fn sync_get_pending(
|
|||||||
/// Mark a sync operation as in progress
|
/// Mark a sync operation as in progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn sync_mark_processing(
|
pub async fn sync_mark_processing(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||||
db: State<'_, DatabaseWrapper>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -131,10 +131,7 @@ pub async fn sync_mark_processing(
|
|||||||
/// Mark a sync operation as completed
|
/// Mark a sync operation as completed
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn sync_mark_completed(
|
pub async fn sync_mark_completed(db: State<'_, DatabaseWrapper>, id: i64) -> Result<(), String> {
|
||||||
db: State<'_, DatabaseWrapper>,
|
|
||||||
id: i64,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use serde::{Serialize, Deserialize};
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::jellyfin::http_client::HttpClient;
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
|
||||||
@@ -170,9 +170,15 @@ impl ConnectivityReporter {
|
|||||||
if let Some(app_handle) = &self.app_handle {
|
if let Some(app_handle) = &self.app_handle {
|
||||||
let event = ConnectivityChangeEvent { is_reachable };
|
let event = ConnectivityChangeEvent { is_reachable };
|
||||||
if let Err(e) = app_handle.emit("connectivity:changed", event) {
|
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 {
|
} 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) {
|
async fn emit_server_reconnected(&self) {
|
||||||
if let Some(app_handle) = &self.app_handle {
|
if let Some(app_handle) = &self.app_handle {
|
||||||
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
|
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 {
|
} else {
|
||||||
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
|
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
|
||||||
}
|
}
|
||||||
@@ -233,7 +242,14 @@ impl ConnectivityMonitor {
|
|||||||
// Check new server immediately
|
// Check new server immediately
|
||||||
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
|
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
|
||||||
let is_reachable = self.check_reachability().await;
|
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
|
/// Get current connectivity status
|
||||||
@@ -298,11 +314,16 @@ impl ConnectivityMonitor {
|
|||||||
return;
|
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.
|
// Perform an immediate check so startup reflects reality quickly.
|
||||||
let is_reachable = self.check_reachability().await;
|
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 is_monitoring = Arc::clone(&self.is_monitoring);
|
||||||
let server_url = Arc::clone(&self.server_url);
|
let server_url = Arc::clone(&self.server_url);
|
||||||
@@ -452,7 +473,9 @@ mod tests {
|
|||||||
let reporter = test_reporter();
|
let reporter = test_reporter();
|
||||||
|
|
||||||
// Force offline.
|
// 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);
|
assert!(!is_reachable(&reporter).await);
|
||||||
|
|
||||||
// A single success brings us straight back online.
|
// A single success brings us straight back online.
|
||||||
@@ -490,7 +513,9 @@ mod tests {
|
|||||||
assert!(!is_reachable(&reporter).await);
|
assert!(!is_reachable(&reporter).await);
|
||||||
|
|
||||||
// Should not panic or change state.
|
// 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);
|
assert!(!is_reachable(&reporter).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,13 +105,21 @@ impl CredentialStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Save an access token for a user
|
/// 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 {
|
if self.using_keyring {
|
||||||
log::debug!("Saving token for user {} to keyring", user_id);
|
log::debug!("Saving token for user {} to keyring", user_id);
|
||||||
self.save_to_keyring(user_id, token)?;
|
self.save_to_keyring(user_id, token)?;
|
||||||
Ok(CredentialResult::Keyring)
|
Ok(CredentialResult::Keyring)
|
||||||
} else {
|
} 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)?;
|
self.save_to_file(user_id, token)?;
|
||||||
log::debug!("Successfully saved token to encrypted file");
|
log::debug!("Successfully saved token to encrypted file");
|
||||||
Ok(CredentialResult::EncryptedFile)
|
Ok(CredentialResult::EncryptedFile)
|
||||||
@@ -124,7 +132,11 @@ impl CredentialStore {
|
|||||||
log::debug!("Getting token for user {} from keyring", user_id);
|
log::debug!("Getting token for user {} from keyring", user_id);
|
||||||
self.get_from_keyring(user_id)
|
self.get_from_keyring(user_id)
|
||||||
} else {
|
} 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);
|
let result = self.get_from_file(user_id);
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
log::debug!("Successfully retrieved token from encrypted file");
|
log::debug!("Successfully retrieved token from encrypted file");
|
||||||
@@ -197,7 +209,7 @@ impl CredentialStore {
|
|||||||
.arg("__nonexistent_test__")
|
.arg("__nonexistent_test__")
|
||||||
.output()
|
.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
|
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
|
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
|
||||||
// See Technical Debt section in README.md for details
|
// See Technical Debt section in README.md for details
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
let key = format!("access_token:{}", user_id);
|
let key = format!("access_token:{}", user_id);
|
||||||
let mut child = Command::new("secret-tool")
|
let mut child = Command::new("secret-tool")
|
||||||
@@ -248,20 +260,27 @@ impl CredentialStore {
|
|||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.spawn()
|
.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() {
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
stdin.write_all(token.as_bytes())
|
stdin.write_all(token.as_bytes()).map_err(|e| {
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
|
CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e))
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = child.wait()
|
let status = child.wait().map_err(|e| {
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
|
CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
if status.success() {
|
if status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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;
|
use std::process::Command;
|
||||||
|
|
||||||
let key = format!("access_token:{}", user_id);
|
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")
|
let output = Command::new("secret-tool")
|
||||||
.arg("lookup")
|
.arg("lookup")
|
||||||
@@ -299,18 +322,29 @@ impl CredentialStore {
|
|||||||
.arg("username")
|
.arg("username")
|
||||||
.arg(&key)
|
.arg(&key)
|
||||||
.output()
|
.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() {
|
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)
|
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()
|
.trim()
|
||||||
.to_string();
|
.to_string();
|
||||||
Ok(token)
|
Ok(token)
|
||||||
} else {
|
} else {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
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)
|
Err(CredentialError::NotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,13 +382,18 @@ impl CredentialStore {
|
|||||||
.arg("username")
|
.arg("username")
|
||||||
.arg(&key)
|
.arg(&key)
|
||||||
.status()
|
.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
|
// secret-tool clear returns success even if entry doesn't exist
|
||||||
if status.success() {
|
if status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
// Try to read Android build properties from /system/build.prop
|
// Try to read Android build properties from /system/build.prop
|
||||||
let build_prop_paths = [
|
let build_prop_paths = ["/system/build.prop", "/vendor/build.prop"];
|
||||||
"/system/build.prop",
|
|
||||||
"/vendor/build.prop",
|
|
||||||
];
|
|
||||||
|
|
||||||
for path in &build_prop_paths {
|
for path in &build_prop_paths {
|
||||||
if let Ok(content) = fs::read_to_string(path) {
|
if let Ok(content) = fs::read_to_string(path) {
|
||||||
@@ -410,7 +446,8 @@ impl CredentialStore {
|
|||||||
if line.starts_with("ro.build.fingerprint=")
|
if line.starts_with("ro.build.fingerprint=")
|
||||||
|| line.starts_with("ro.serialno=")
|
|| line.starts_with("ro.serialno=")
|
||||||
|| line.starts_with("ro.build.id=")
|
|| line.starts_with("ro.build.id=")
|
||||||
|| line.starts_with("ro.product.model=") {
|
|| line.starts_with("ro.product.model=")
|
||||||
|
{
|
||||||
hasher.update(line.as_bytes());
|
hasher.update(line.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,8 +476,8 @@ impl CredentialStore {
|
|||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let encrypted_data =
|
let encrypted_data = fs::read_to_string(&self.credentials_path)
|
||||||
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
|
.map_err(|e| CredentialError::Io(e.to_string()))?;
|
||||||
|
|
||||||
if encrypted_data.is_empty() {
|
if encrypted_data.is_empty() {
|
||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
@@ -456,19 +493,21 @@ impl CredentialStore {
|
|||||||
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
|
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)?;
|
let encrypted = self.encrypt(&json)?;
|
||||||
|
|
||||||
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
|
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
|
||||||
let cipher =
|
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
// Generate a random nonce
|
// Generate a random nonce
|
||||||
let mut nonce_bytes = [0u8; 12];
|
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 nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
let ciphertext = cipher
|
let ciphertext = cipher
|
||||||
@@ -488,14 +527,16 @@ impl CredentialStore {
|
|||||||
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
if combined.len() < 12 {
|
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_bytes, ciphertext) = combined.split_at(12);
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
|
||||||
let cipher =
|
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
|
||||||
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
|
||||||
|
|
||||||
let plaintext = cipher
|
let plaintext = cipher
|
||||||
.decrypt(nonce, ciphertext)
|
.decrypt(nonce, ciphertext)
|
||||||
@@ -686,32 +727,39 @@ mod android_keystore {
|
|||||||
.attach_current_thread()
|
.attach_current_thread()
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
let instance = get_secure_storage_instance(&mut env)
|
let instance =
|
||||||
.map_err(|e| CredentialError::Keyring(e))?;
|
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
let key = format!("access_token:{}", user_id);
|
let key = format!("access_token:{}", user_id);
|
||||||
let key_jstring = env
|
let key_jstring = env
|
||||||
.new_string(&key)
|
.new_string(&key)
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
|
||||||
let token_jstring = env
|
let token_jstring = env.new_string(token).map_err(|e| {
|
||||||
.new_string(token)
|
CredentialError::Keyring(format!("Failed to create token string: {}", e))
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
|
})?;
|
||||||
|
|
||||||
let result = env
|
let result = env
|
||||||
.call_method(
|
.call_method(
|
||||||
instance,
|
instance,
|
||||||
"saveToken",
|
"saveToken",
|
||||||
"(Ljava/lang/String;Ljava/lang/String;)Z",
|
"(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)))?
|
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
|
||||||
.z()
|
.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 {
|
if result {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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()
|
.attach_current_thread()
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
let instance = get_secure_storage_instance(&mut env)
|
let instance =
|
||||||
.map_err(|e| CredentialError::Keyring(e))?;
|
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
let key = format!("access_token:{}", user_id);
|
let key = format!("access_token:{}", user_id);
|
||||||
let key_jstring = env
|
let key_jstring = env
|
||||||
@@ -767,8 +815,8 @@ mod android_keystore {
|
|||||||
.attach_current_thread()
|
.attach_current_thread()
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
|
||||||
|
|
||||||
let instance = get_secure_storage_instance(&mut env)
|
let instance =
|
||||||
.map_err(|e| CredentialError::Keyring(e))?;
|
get_secure_storage_instance(&mut env).map_err(|e| CredentialError::Keyring(e))?;
|
||||||
|
|
||||||
let key = format!("access_token:{}", user_id);
|
let key = format!("access_token:{}", user_id);
|
||||||
let key_jstring = env
|
let key_jstring = env
|
||||||
@@ -784,19 +832,25 @@ mod android_keystore {
|
|||||||
)
|
)
|
||||||
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
|
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
|
||||||
.z()
|
.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 {
|
if result {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} 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
|
// Export Android keystore functions at the module level for easier access
|
||||||
#[cfg(target_os = "android")]
|
#[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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_enabled: true,
|
||||||
album_affinity_threshold: 3,
|
album_affinity_threshold: 3,
|
||||||
storage_limit: 10 * 1024 * 1024 * 1024, // 10GB
|
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 {
|
pub fn should_precache_queue(&self) -> bool {
|
||||||
self.config
|
self.config
|
||||||
.lock()
|
.lock()
|
||||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
.map(|cfg| cfg.queue_precache_enabled)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +234,10 @@ impl SmartCache {
|
|||||||
"DELETE FROM downloads WHERE id = ?",
|
"DELETE FROM downloads WHERE id = ?",
|
||||||
vec![QueryParam::Int64(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;
|
freed += size as u64;
|
||||||
}
|
}
|
||||||
@@ -279,6 +291,19 @@ mod tests {
|
|||||||
assert!(cache.should_precache_queue());
|
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]
|
#[tokio::test]
|
||||||
async fn test_storage_limit_check() {
|
async fn test_storage_limit_check() {
|
||||||
use crate::storage::db_service::RusqliteService;
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
|||||||