Compare commits

...
17 Commits
Author SHA1 Message Date
dtourolle dcf08f30bc fix: Autoplay now resets time to zero and ignores trigger if episode already started (#3)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m48s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 3m27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m31s
Build & Release / Build Linux (push) Successful in 15m52s
Build & Release / Build Android (push) Successful in 18m43s
Build & Release / Create Release (push) Successful in 12s
Reviewed-on: #3
Co-authored-by: Duncan Tourolle <duncan@tourolle.paris>
Co-committed-by: Duncan Tourolle <duncan@tourolle.paris>
2026-06-23 21:12:01 +00:00
dtourolle 674c8e5cd0 ci(release): fix release-notes generation breaking jq publish step
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m11s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 3m40s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m36s
Build & Release / Build Linux (push) Successful in 15m56s
Build & Release / Build Android (push) Successful in 18m33s
Build & Release / Create Release (push) Successful in 7s
The release-notes echo lines used unescaped backticks, which the shell ran
as command substitution; their output leaked control characters into
release_notes.md, so jq failed with 'Invalid string: control characters ...
must be escaped' when building the release payload.

- Escape the backticks so they are literal markdown.
- Remove emoji from the release-notes content (plain ASCII headings).
- Handle an already-existing release (HTTP 409) by reusing its id for
  asset upload instead of failing.
2026-06-23 20:38:09 +02:00
dtourolleandClaude Opus 4.8 45aa029916 fix(connectivity): drive reachability from real repository traffic
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m32s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m27s
Build & Release / Run Tests (push) Successful in 3m36s
Build & Release / Build Linux (push) Successful in 15m43s
Build & Release / Build Android (push) Successful in 18m40s
Build & Release / Create Release (push) Failing after 22s
The offline/online switch was janky because two independent systems decided
"online" and never communicated:

- ConnectivityMonitor owned is_server_reachable (drove the UI banner) but
  learned reachability only from a standalone /System/Info/Public ping loop
  and from auth/login calls.
- HybridRepository served all real data by racing cache-vs-server but never
  read or wrote reachability.

So the banner reflected a side-channel poller, not the system the user actually
experienced: a successful ping could read "online" while authenticated data
calls 401'd or timed out, and three different timeout regimes (5s ping / 30s
data / 100ms cache race) flapped against each other.

Unify into a single source of truth:

- Extract a cheap, cloneable ConnectivityReporter that owns all reachability
  transitions and event emission.
- OnlineRepository reports the outcome of every server request to the reporter,
  classified via RepoError: Ok/Authentication/NotFound/Server => reachable
  (the server answered), Network => offline candidate, Database/Offline =>
  ignored (not a server signal).
- Time-window debounce (OFFLINE_CONFIRM_WINDOW = 5s): flip offline only after
  sustained network failure; recover instantly on the first success.
- Demote the ping loop to an offline-only recovery probe (no online polling;
  real traffic is the signal when online).
- Frontend: navigator.onLine is now advisory (triggers a recheck instead of
  forcing offline); removed the dead markReachable/markUnreachable store methods.

Docs updated (README, 07-connectivity, 03-data-flow, 02-svelte-frontend) to
describe the new model and fix pre-existing drift (HTTP client is 30s timeout +
5s ping, not the documented 10s/base_url).

Tests: 12 connectivity tests (debounce, instant recovery, RepoError
classification through report_outcome). Full suite: 398 Rust + 384 frontend
passing, svelte-check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:56:14 +02:00
dtourolleandClaude Opus 4.8 3faa595b76 fix(android): track VideoOverlayManager.kt in canonical source tree
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m28s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 3m11s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 17m51s
Build & Release / Build Linux (push) Successful in 15m29s
Build & Release / Build Android (push) Failing after 18m9s
Build & Release / Create Release (push) Has been skipped
JellyTauPlayer.kt references com.dtourolle.jellytau.VideoOverlayManager,
but the file existed only in the gitignored gen/android dir, so it
survived locally but vanished in CI (which regenerates gen/android via
'tauri android init'). sync-android-sources.sh copies top-level *.kt
from src-tauri/android, so adding it there gets it synced into the build.

Fixes: 'Unresolved reference: VideoOverlayManager' in
:app:compileUniversalReleaseKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:55:30 +02:00
dtourolleandClaude Opus 4.8 7fb866a583 ci: fix release Android build --apk flag requires explicit value
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 2m26s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 2m29s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 17m57s
Build & Release / Build Linux (push) Successful in 15m38s
Build & Release / Build Android (push) Failing after 14m48s
Build & Release / Create Release (push) Has been skipped
Tauri CLI requires '--apk true'; bare '--apk' fails with
"a value is required for '--apk <APK>'". The release workflow
only reached this step now that checkout/container issues are fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:44:23 +02:00
dtourolle 0c3ed74fe1 ci: Improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m47s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 2m27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 28m0s
Build & Release / Build Linux (push) Successful in 15m45s
Build & Release / Build Android (push) Failing after 49s
Build & Release / Create Release (push) Has been skipped
2026-06-21 17:38:06 +02:00
dtourolle a5b6266a6d Permanent CI fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m54s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 1h14m25s
Build & Release / Run Tests (push) Failing after 7s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-06-21 10:48:26 +02:00
dtourolle a816c84f8c fix CI
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:35:14 +02:00
dtourolle 87762c03b6 Fix linux playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m38s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-21 10:16:25 +02:00
dtourolle 975936b902 Merge pull request 'chore: clean up repo organization' (#2) from chore/repo-cleanup into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m44s
Traceability Validation / Check Requirement Traces (push) Successful in 30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Reviewed-on: #2
2026-06-21 07:53:30 +00:00
dtourolle 5ba9e0e958 chore: clean up repo organization
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 5m6s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 30s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 19m30s
- Standardize on bun: remove package-lock.json, add packageManager field,
  gitignore non-bun lockfiles, fix stray npm install in android:build:clean
- Remove stale build logs and empty dirs (src-tauri/plugins, docs/tickets)
- Move android-dev.sh into scripts/
- Consolidate root docs into docs/ (docker/builder under docs/build/);
  move the architecture overview to docs/architecture/README.md
- Extract Requirements Specification from README into docs/requirements.md
  and slim README down to a project intro + docs index
- Fix internal references to the moved files
2026-06-21 09:52:09 +02:00
dtourolle ccc9dca924 fix: resolve all Rust test compile warnings without suppression
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m16s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Remove unused imports and a dead test helper, and strengthen tests that
held unused bindings/fields so the values are actually exercised:
- drop unused imports (HttpConfig, MediaType, std::io::Write)
- remove unused TestEventEmitter::clear helper
- replace meaningless size_of asserts with real empty-manager checks
- assert on MockTrack.name in sorting tests
2026-06-21 09:33:50 +02:00
dtourolle 60bb6c72d1 ci: stop committing machine-specific Android cargo config
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m10s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
src-tauri/.cargo/config.toml hardcoded absolute NDK paths under
/home/dtourolle, which only exist on the dev laptop. In CI this made
ring's build script fail to find aarch64-linux-android34-clang. Tauri
derives the linker/CC paths from NDK_HOME automatically during
'android build', so the file is unnecessary there. Untrack it and
gitignore it; local dev copies are preserved on disk.
2026-06-21 09:22:34 +02:00
dtourolle 37cc9b424d ci: ensure Android NDK is available in build-and-test job
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m48s
Traceability Validation / Check Requirement Traces (push) Successful in 27s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 5m32s
Set ANDROID_HOME/NDK_HOME/NDK_VERSION at the job level and add a step
that installs the NDK via sdkmanager when missing, so the Android init
step no longer fails when the builder image lacks NDK_HOME.
2026-06-21 09:10:41 +02:00
dtourolle 2daee7ec2d fix extract traces
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m10s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Failing after 2m20s
2026-06-21 08:48:39 +02:00
dtourolle 01c6423154 Merge pull request 'Migrate all IPC call sites to typed tauri-specta commands.*' (#1) from ipc-commands-migration into master
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Reviewed-on: #1
2026-06-21 06:48:08 +00:00
dtourolle d01c2aab9f Migrate all IPC call sites to typed tauri-specta commands.*
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Replace the remaining ~155 untyped invoke() calls across stores, services,
components, and routes with the generated commands.* wrappers from
$lib/api/bindings, so every IPC call is compile-time-checked against the
command signatures.

- Register repository_get_subtitle_url and repository_get_video_download_url
  in specta_builder() and the invoke_handler; regenerate bindings.ts.
- Source duplicated wire types (AutoplaySettings, CacheConfig, Session,
  ConnectivityStatus, audio/video settings, etc.) from bindings.
- Fix two bugs surfaced by the typed wrappers:
  - VideoDownloadButton passed an un-awaited Promise as the stream URL.
  - setAutoplaySettings omitted the required userId argument.
- Update unit tests asserting the old invoke(name, args) shape.
- Remove the five param-naming guard tests; the compiler and codegen now
  enforce what they checked.

svelte-check: 0 errors. vitest: green. cargo test --lib: green.
2026-06-21 08:47:04 +02:00
80 changed files with 1936 additions and 13362 deletions
+18 -5
View File
@@ -31,9 +31,9 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
src-tauri/target src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-host-
- name: Cache Node dependencies - name: Cache Node dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -66,6 +66,10 @@ jobs:
needs: test needs: test
container: container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 27.0.11902837
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -78,9 +82,9 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
src-tauri/target src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-android-
- name: Cache Node dependencies - name: Cache Node dependencies
uses: actions/cache@v3 uses: actions/cache@v3
@@ -99,6 +103,15 @@ jobs:
- name: Build frontend - name: Build frontend
run: bun run build run: bun run build
- name: Ensure Android NDK
run: |
if [ ! -d "$NDK_HOME" ]; then
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
fi
echo "Using NDK at $NDK_HOME"
ls "$NDK_HOME"
- name: Initialize Android project - name: Initialize Android project
run: | run: |
cd src-tauri cd src-tauri
@@ -109,7 +122,7 @@ jobs:
id: build id: build
run: | run: |
mkdir -p artifacts mkdir -p artifacts
bun run tauri android build --apk true bun run tauri android build --apk true --target aarch64
# Find the generated APK file # Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit) ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
+79 -103
View File
@@ -18,37 +18,40 @@ jobs:
test: test:
name: Run Tests name: Run Tests
runs-on: linux/amd64 runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/ key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-host-
- 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: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Run frontend tests - name: Run frontend tests
run: bun run test --run run: |
bunx svelte-kit sync
bun run test --run
continue-on-error: false continue-on-error: false
- name: Run Rust tests - name: Run Rust tests
@@ -63,45 +66,32 @@ jobs:
name: Build Linux name: Build Linux
runs-on: linux/amd64 runs-on: linux/amd64
needs: test needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/ key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo- ${{ runner.os }}-cargo-host-
- 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: Install dependencies - name: Install dependencies
run: bun install run: bun install
@@ -135,59 +125,40 @@ jobs:
name: Build Android name: Build Android
runs-on: linux/amd64 runs-on: linux/amd64
needs: test needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
with:
api-level: 33
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Add Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Install Android NDK
run: |
sdkmanager "ndk;25.1.8937393"
- name: Cache Rust dependencies - name: Cache Rust dependencies
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: | path: |
~/.cargo/bin/ ~/.cargo/registry
~/.cargo/registry/index/ ~/.cargo/git
~/.cargo/registry/cache/ src-tauri/target
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-cargo-android- ${{ runner.os }}-cargo-android-
- 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: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Resolve Android NDK path
run: echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/25.1.8937393" >> "$GITHUB_ENV"
- name: Set app version from tag - name: Set app version from tag
run: | run: |
REF="${GITHUB_REF#refs/tags/v}" REF="${GITHUB_REF#refs/tags/v}"
@@ -201,8 +172,6 @@ jobs:
- name: Initialize Android project - name: Initialize Android project
run: bun run tauri android init run: bun run tauri android init
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
- 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
@@ -218,11 +187,7 @@ jobs:
EOF EOF
- name: Build signed Android APK - name: Build signed Android APK
run: bun run tauri android build --apk run: bun run tauri android build --apk true --target aarch64
env:
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
ANDROID_SDK_ROOT: ${{ env.ANDROID_SDK_ROOT }}
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
- name: Collect & verify signed APK - name: Collect & verify signed APK
run: | run: |
@@ -247,6 +212,8 @@ jobs:
runs-on: linux/amd64 runs-on: linux/amd64
needs: [build-linux, build-android] needs: [build-linux, build-android]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -273,23 +240,23 @@ jobs:
id: release_notes id: release_notes
run: | run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}" VERSION="${{ steps.tag_name.outputs.VERSION }}"
echo "## 📱 JellyTau $VERSION Release" > release_notes.md echo "## JellyTau $VERSION Release" > release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 📦 Downloads" >> release_notes.md echo "### Downloads" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "#### Linux" >> release_notes.md echo "#### Linux" >> release_notes.md
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 "#### 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
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### What's New" >> release_notes.md echo "### What's New" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 🔧 Installation" >> release_notes.md echo "### Installation" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "#### Linux (AppImage)" >> release_notes.md echo "#### Linux (AppImage)" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md echo "\`\`\`bash" >> release_notes.md
@@ -307,11 +274,11 @@ jobs:
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
echo "- Play Store: Coming soon" >> release_notes.md echo "- Play Store: Coming soon" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 🐛 Known Issues" >> release_notes.md echo "### Known Issues" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "### 📝 Requirements" >> release_notes.md echo "### Requirements" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "**Linux:**" >> release_notes.md echo "**Linux:**" >> release_notes.md
echo "- 64-bit Linux system" >> release_notes.md echo "- 64-bit Linux system" >> release_notes.md
@@ -322,7 +289,7 @@ jobs:
echo "- 50MB free storage" >> release_notes.md echo "- 50MB free storage" >> release_notes.md
echo "" >> release_notes.md echo "" >> release_notes.md
echo "---" >> release_notes.md echo "---" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
- name: Publish Gitea release & upload assets - name: Publish Gitea release & upload assets
env: env:
@@ -346,11 +313,20 @@ jobs:
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}') '{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
echo "📦 Creating release $VERSION on $REPO" echo "📦 Creating release $VERSION on $REPO"
RESP=$(curl -fsS -X POST "$API/repos/$REPO/releases" \ # -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$PAYLOAD") -d "$PAYLOAD")
RELEASE_ID=$(echo "$RESP" | jq -r '.id') if [ "$HTTP" = "201" ]; then
RELEASE_ID=$(jq -r '.id' resp.json)
elif [ "$HTTP" = "409" ]; then
echo "️ Release $VERSION already exists; fetching its id to upload assets"
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
-H "Authorization: token $TOKEN" | jq -r '.id')
else
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
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/*; do
+15 -7
View File
@@ -95,20 +95,28 @@ jobs:
echo "" echo ""
# Check each file # Check each file
MISSING_TRACES=0 # Pipe into the loop instead of a here-string (<<<) so this step works
while IFS= read -r file; do # under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
# for the same reason. The loop runs in a subshell (so a counter var
# wouldn't survive), so we record warnings in a temp file and count it
# afterwards.
MISSING_FILE=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
# Skip test files # Skip test files
if [[ "$file" == *".test."* ]]; then case "$file" in
continue *.test.*) continue ;;
fi esac
if [ -f "$file" ]; then if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file" echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1)) echo "$file" >> "$MISSING_FILE"
fi fi
fi fi
done <<< "$CHANGED" done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then if [ "$MISSING_TRACES" -gt 0 ]; then
echo "" echo ""
+8
View File
@@ -9,6 +9,11 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
package-lock.json
yarn.lock
pnpm-lock.yaml
# Build output # Build output
/build /build
/dist /dist
@@ -50,3 +55,6 @@ logs
# Android signing keystore (NEVER commit) # Android signing keystore (NEVER commit)
android-keystore/ android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
+8 -3
View File
@@ -7,7 +7,8 @@ FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \ ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \ NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \ SDK_VERSION=36 \
BUILD_TOOLS_VERSION=35.0.0 \
RUST_BACKTRACE=1 \ RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \ PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo CARGO_HOME=/root/.cargo
@@ -68,10 +69,14 @@ RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-1107
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \ mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
# Install Android SDK components # Accept all SDK licenses up front so Gradle can install/use components non-interactively
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platform-tools" \
"platforms;android-$SDK_VERSION" \ "platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \ "build-tools;$BUILD_TOOLS_VERSION" \
"ndk;$NDK_VERSION" \ "ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true --channel=0 2>&1 | grep -v "Warning" || true
+27 -477
View File
@@ -2,335 +2,25 @@
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript. A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
## Recommended IDE Setup Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
Android (ExoPlayer).
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). ## Getting Started
--- This project uses [bun](https://bun.sh) as its package manager.
# Requirements Specification
## 1. User Requirements
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
| UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
| UR-020 | Select subtitles for video content | High | Done |
| UR-021 | Select audio track for video content | High | Done |
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | 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-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-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | 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-039 | Navigate between main sections via bottom navigation bar | High | Done |
---
## 2. Software Requirements
### 2.1 Integration Requirements
External system integrations and platform-specific implementations.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration.
| ID | Requirement | Endpoint Category | Traces To | Status |
|----|-------------|-------------------|-----------|--------|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
| JA-003 | Get user library views | UserViews | UR-007 | Done |
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
| JA-006 | Search across libraries | Items | UR-008 | Done |
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
| JA-010 | Report playback start | Sessions | UR-025 | Done |
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
| JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
| JA-028 | Get image/artwork URLs | Images | UR-007 | 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-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
### 2.3 Development Requirements
Internal architecture, components, and application logic.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | 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-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | 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-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
---
## 3. Traceability Matrix
### User Requirements to Software Requirements
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
| UR-029 | - | DR-032 |
| UR-030 | IR-010 | DR-033 |
| UR-031 | - | DR-034 |
| UR-032 | - | DR-035 |
| UR-033 | - | DR-036 |
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
| UR-036 | IR-022, IR-023 | DR-041 |
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
---
## 4. Test Traceability
### Unit Tests to Software Requirements
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| UT-001 | Player state transitions | DR-001 | Pending |
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
| UT-010 | Download queue management | DR-015 | Done |
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
| UT-016 | All database tables created | IR-013, DR-012 | Done |
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
| UT-026 | NullBackend volume default value | DR-004 | Done |
| UT-027 | NullBackend set volume | DR-004 | Done |
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
| UT-035 | Download event completed serialization | DR-015 | Done |
| UT-036 | Download event failed serialization | DR-015 | Done |
| UT-037 | Download worker exponential backoff | DR-015 | Done |
| UT-038 | Download worker error retryable check | DR-015 | Done |
| UT-039 | Download manager creation | DR-015 | Done |
| UT-040 | Download manager set max concurrent | DR-015 | Done |
| UT-041 | Download info serialization | DR-015 | Done |
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
| UT-044 | Offline item serialization | DR-017 | Done |
| UT-045 | Smart cache default config | DR-015 | Done |
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | DR-015 | Done |
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | 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-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | 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-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
---
## 5. Development Commands
```bash ```bash
# Activate Rust environment (fish shell) # Activate the Rust environment (fish shell)
source "$HOME/.cargo/env.fish" source "$HOME/.cargo/env.fish"
# Install dependencies # Install dependencies
bun install bun install
# Development # Run in development
bun run tauri dev bun run tauri dev
# Type checking # Type-check the frontend
bun run check bun run check
# Build for Linux # Build for Linux
@@ -340,168 +30,28 @@ bun run tauri build
bun run tauri android build bun run tauri android build
``` ```
--- For the full set of build, test, and Android helper scripts, see
[scripts/README.md](scripts/README.md).
## 6. Architecture Overview ## Documentation
``` | Topic | Location |
jellytau/ |-------|----------|
├── src/ # Svelte frontend | Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
│ ├── lib/ | Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
│ │ ├── api/ # Jellyfin API client (repository pattern) | Build & release process | [docs/build-release.md](docs/build-release.md) |
│ │ ├── components/ # UI components (player, library) | Docker builds | [docs/build/docker.md](docs/build/docker.md) |
│ │ └── stores/ # Svelte stores (auth, library, player, queue) | Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
│ └── routes/ # SvelteKit pages | Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
├── src-tauri/ # Rust backend | UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
│ ├── src/
│ │ ├── commands/ # Tauri commands
│ │ └── player/ # Player architecture
│ │ ├── state.rs # State machine
│ │ ├── media.rs # MediaItem, MediaSource
│ │ ├── queue.rs # Queue management
│ │ └── backend.rs # PlayerBackend trait
│ └── gen/android/ # Android project
└── README.md
```
--- ## Recommended IDE Setup
## 7. Technical Debt [VS Code](https://code.visualstudio.com/) +
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
### Linux Keyring Integration Workaround ## License
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them. MIT
**Symptoms**:
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
- Session restoration fails on app restart even though credentials exist
**Root Cause**:
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
**Current Workaround**:
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
- **Save**: `secret-tool store --label <label> service <service> username <username>`
- **Retrieve**: `secret-tool lookup service <service> username <username>`
- **Delete**: `secret-tool clear service <service> username <username>`
**Implementation**:
See [src-tauri/src/credentials.rs](src-tauri/src/credentials.rs):
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
**Future Fix**:
- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
**Impact**:
- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
**Dependencies**:
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default
---
### Platform Playback Backend Parity (Linux vs Android)
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
**Affected Files**:
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
**Feature Parity Matrix**:
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|---------|-------------|---------------------|--------|
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | ✅ | ❌ | Gap |
| Gapless playback | ✅ | ❌ | Gap |
| Volume normalization | ✅ | ❌ | Gap |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
---
### Frontend Playback Code Duplication
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
**Symptoms**:
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
**Duplicated Code**:
```typescript
// These handlers are identical in both AudioPlayer and MiniPlayer:
handlePlayPause(), handleNext(), handlePrevious(),
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
// These derived states use identical logic:
displayMedia, displayIsPlaying, displayPosition, displayDuration
```
**Future Fix**:
1. Create `src/lib/utils/playbackUnits.ts`:
```typescript
export const TICKS_PER_SECOND = 10_000_000;
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
```
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
- Single source of truth for merged local/remote state
3. Simplify handler wrappers using a utility:
```typescript
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
```
**Impact**:
- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time
**Traces To**: DR-009
+6 -3
View File
@@ -176,7 +176,10 @@ classDiagram
-server_url: String -server_url: String
-user_id: String -user_id: String
-access_token: String -access_token: String
-connectivity: Option~Arc~ConnectivityMonitor~~
+new() +new()
+with_connectivity()
-report_outcome()
} }
class OfflineRepository { class OfflineRepository {
@@ -192,10 +195,9 @@ classDiagram
class HybridRepository { class HybridRepository {
-online: Arc~OnlineRepository~ -online: Arc~OnlineRepository~
-offline: Arc~OfflineRepository~ -offline: Arc~OfflineRepository~
-connectivity: Arc~ConnectivityMonitor~
+new() +new()
-parallel_query() -parallel_race()
-has_meaningful_content() -cache_with_timeout()
} }
MediaRepository <|.. OnlineRepository MediaRepository <|.. OnlineRepository
@@ -214,6 +216,7 @@ classDiagram
- Returns cache result if it has meaningful content - Returns cache result if it has meaningful content
- Falls back to server result otherwise - Falls back to server result otherwise
- Background cache updates planned - Background cache updates planned
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
2. **Handle-Based Resource Management** (`repository.rs` commands): 2. **Handle-Based Resource Management** (`repository.rs` commands):
```rust ```rust
+9
View File
@@ -10,6 +10,7 @@ sequenceDiagram
participant Hybrid as HybridRepository participant Hybrid as HybridRepository
participant Cache as OfflineRepository (SQLite) participant Cache as OfflineRepository (SQLite)
participant Server as OnlineRepository (HTTP) participant Server as OnlineRepository (HTTP)
participant Conn as ConnectivityMonitor
UI->>Client: getItems(parentId) UI->>Client: getItems(parentId)
Client->>Rust: invoke("repository_get_items", {handle, parentId}) Client->>Rust: invoke("repository_get_items", {handle, parentId})
@@ -20,6 +21,13 @@ sequenceDiagram
Hybrid->>Server: get_items() (no timeout) Hybrid->>Server: get_items() (no timeout)
end end
Note over Server,Conn: Every server request reports its outcome
alt Server succeeds (or answers with 4xx/5xx)
Server->>Conn: mark_reachable() (server is up)
else Network failure / timeout
Server->>Conn: mark_unreachable() (debounced)
end
alt Cache returns with content alt Cache returns with content
Cache-->>Hybrid: Result with items Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result Hybrid-->>Rust: Return cache result
@@ -39,6 +47,7 @@ sequenceDiagram
- Cache wins if it has meaningful content - Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale - Automatic fallback to server if cache is empty/stale
- Background cache updates (planned) - Background cache updates (planned)
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
## Playback Initiation Flow ## Playback Initiation Flow
+61 -26
View File
@@ -13,12 +13,13 @@ pub struct HttpClient {
} }
pub struct HttpConfig { pub struct HttpConfig {
pub base_url: String, pub timeout: Duration, // Default: 30s (large library queries can be slow)
pub timeout: Duration, // Default: 10s
pub max_retries: u32, // Default: 3 pub max_retries: u32, // Default: 3
} }
``` ```
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
**Retry Strategy:** **Retry Strategy:**
- Retry delays: 1s, 2s, 4s (exponential backoff) - Retry delays: 1s, 2s, 4s (exponential backoff)
- Retries on: Network errors, 5xx server errors - Retries on: Network errors, 5xx server errors
@@ -38,39 +39,71 @@ pub enum ErrorKind {
**Location**: `src-tauri/src/connectivity/mod.rs` **Location**: `src-tauri/src/connectivity/mod.rs`
The connectivity monitor tracks server reachability with adaptive polling: The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
### Source of truth: repository traffic
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
| Repository outcome | Meaning | Effect on reachability |
|--------------------|---------|------------------------|
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
### Time-window debounce (offline) + instant recovery (online)
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
- On the **first** `Network` failure, the monitor records `first_failure_at`.
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
### Offline-only recovery probe
```mermaid ```mermaid
flowchart TB flowchart TB
Monitor["ConnectivityMonitor"] --> Poller["Background Task"] Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
Poller --> Check{"Server<br/>Reachable?"} Monitor --> State{"is_server_reachable?"}
Check -->|"Yes"| Online["30s Interval"] State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
Check -->|"No"| Offline["5s Interval"] State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
Online --> Emit["Emit Events"] Probe -->|"reachable again"| Monitor
Offline --> Emit Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
Emit --> Frontend["Frontend Store"] Emit --> Frontend["Frontend Store → banner"]
``` ```
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
**Features:** **Features:**
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection) - **Traffic-driven**: Reachability follows the requests the user actually makes.
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events - **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
- **Manual Marking**: Can mark reachable/unreachable based on API call results - **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state - **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
**Tauri Commands:** **Tauri Commands:**
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `connectivity_check_server` | Manual reachability check | | `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
| `connectivity_set_server_url` | Update monitored server URL | | `connectivity_set_server_url` | Update monitored server URL |
| `connectivity_get_status` | Get current connectivity status | | `connectivity_get_status` | Get current connectivity status |
| `connectivity_start_monitoring` | Start background monitoring | | `connectivity_start_monitoring` | Start the offline recovery probe |
| `connectivity_stop_monitoring` | Stop monitoring | | `connectivity_stop_monitoring` | Stop the probe |
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) | | `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) | | `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
**Frontend Integration:** **Frontend Integration:**
```typescript ```typescript
// TypeScript store listens to Rust events // The store is a pure reflection of backend events — it no longer decides
// reachability itself. navigator.onLine is advisory: it triggers an immediate
// recheck rather than forcing the offline state.
listen<{ isReachable: boolean }>("connectivity:changed", (event) => { listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
updateConnectivityState(event.payload.isReachable); updateConnectivityState(event.payload.isReachable);
}); });
@@ -81,12 +114,14 @@ listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
The connectivity system provides resilience through multiple layers: The connectivity system provides resilience through multiple layers:
1. **HTTP Client Layer**: Automatic retry with exponential backoff 1. **HTTP Client Layer**: Automatic retry with exponential backoff
2. **Connectivity Monitoring**: Background reachability checks 2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
3. **Frontend Integration**: Offline mode detection and UI updates 3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md)) 4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
**Design Principles:** **Design Principles:**
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication) - **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
- **Fail Slow**: Retry network and 5xx errors with increasing delays - **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline - **Fail Slow**: Retry network and 5xx errors with increasing delays.
- **Event-Driven**: Frontend reacts to connectivity changes via events - **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
- **Event-Driven**: Frontend reacts to connectivity changes via events.
@@ -15,6 +15,7 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`). - **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- **Handle-Based Resources**: UUID handles for stateful Rust objects. - **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback. - **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player. - **Poison-tolerant locking**: Shared `std::sync` state is accessed 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 (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing. - **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
@@ -79,26 +80,29 @@ flowchart TB
Core --> Storage Core --> Storage
Repository --> HttpClient Repository --> HttpClient
Repository --> DatabaseService Repository --> DatabaseService
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
end end
``` ```
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
--- ---
## Detailed Documentation ## Detailed Documentation
Each major subsystem is documented in its own file under [docs/architecture/](docs/architecture/): Each major subsystem is documented in its own file in this directory:
| Document | Contents | | Document | Contents |
|----------|----------| |----------|----------|
| [01 - Rust Backend](docs/architecture/01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands | | [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
| [02 - Svelte Frontend](docs/architecture/02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI | | [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
| [03 - Data Flow](docs/architecture/03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control | | [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](docs/architecture/04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns | | [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](docs/architecture/05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization | | [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](docs/architecture/06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components | | [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](docs/architecture/07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture | | [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](docs/architecture/08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates | | [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
| [09 - Security](docs/architecture/09-security.md) | Authentication token storage, secure storage module, network security, local data protection | | [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
--- ---
@@ -187,7 +191,7 @@ src/lib/
**What moved to Rust (~3,500 lines of business logic):** **What moved to Rust (~3,500 lines of business logic):**
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff 1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
2. **Connectivity Monitor** (301 lines) - Adaptive polling, event emission 2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing 3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
4. **Database Service** - Async wrapper preventing UI freezing 4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination 5. **Playback Mode** (303 lines) - Local/remote transfer coordination
View File
+454
View File
@@ -0,0 +1,454 @@
# Requirements Specification
This document captures JellyTau's user requirements, software requirements,
traceability matrix, test traceability, and known technical debt.
For a narrative overview of the system design, see
[docs/architecture/](architecture/). For development workflows, see the
[README](../README.md) and [scripts/README.md](../scripts/README.md).
## 1. User Requirements
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
| UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
| UR-020 | Select subtitles for video content | High | Done |
| UR-021 | Select audio track for video content | High | Done |
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | 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-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-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | 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-039 | Navigate between main sections via bottom navigation bar | High | Done |
---
## 2. Software Requirements
### 2.1 Integration Requirements
External system integrations and platform-specific implementations.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration.
| ID | Requirement | Endpoint Category | Traces To | Status |
|----|-------------|-------------------|-----------|--------|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
| JA-003 | Get user library views | UserViews | UR-007 | Done |
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
| JA-006 | Search across libraries | Items | UR-008 | Done |
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
| JA-010 | Report playback start | Sessions | UR-025 | Done |
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
| JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
| JA-028 | Get image/artwork URLs | Images | UR-007 | 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-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
### 2.3 Development Requirements
Internal architecture, components, and application logic.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | 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-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | 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-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
---
## 3. Traceability Matrix
### User Requirements to Software Requirements
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
| UR-029 | - | DR-032 |
| UR-030 | IR-010 | DR-033 |
| UR-031 | - | DR-034 |
| UR-032 | - | DR-035 |
| UR-033 | - | DR-036 |
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
| UR-036 | IR-022, IR-023 | DR-041 |
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
---
## 4. Test Traceability
### Unit Tests to Software Requirements
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| UT-001 | Player state transitions | DR-001 | Pending |
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
| UT-010 | Download queue management | DR-015 | Done |
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
| UT-016 | All database tables created | IR-013, DR-012 | Done |
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
| UT-026 | NullBackend volume default value | DR-004 | Done |
| UT-027 | NullBackend set volume | DR-004 | Done |
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
| UT-035 | Download event completed serialization | DR-015 | Done |
| UT-036 | Download event failed serialization | DR-015 | Done |
| UT-037 | Download worker exponential backoff | DR-015 | Done |
| UT-038 | Download worker error retryable check | DR-015 | Done |
| UT-039 | Download manager creation | DR-015 | Done |
| UT-040 | Download manager set max concurrent | DR-015 | Done |
| UT-041 | Download info serialization | DR-015 | Done |
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
| UT-044 | Offline item serialization | DR-017 | Done |
| UT-045 | Smart cache default config | DR-015 | Done |
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | DR-015 | Done |
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | 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-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | 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-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
---
## 5. Technical Debt
### Linux Keyring Integration Workaround
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
**Symptoms**:
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
- Session restoration fails on app restart even though credentials exist
**Root Cause**:
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
**Current Workaround**:
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
- **Save**: `secret-tool store --label <label> service <service> username <username>`
- **Retrieve**: `secret-tool lookup service <service> username <username>`
- **Delete**: `secret-tool clear service <service> username <username>`
**Implementation**:
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
Linux-specific `secret-tool` save/get/delete paths.
**Future Fix**:
- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
**Impact**:
- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
**Dependencies**:
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default
---
### Platform Playback Backend Parity (Linux vs Android)
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
**Affected Files**:
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
**Feature Parity Matrix**:
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|---------|-------------|---------------------|--------|
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | ✅ | ❌ | Gap |
| Gapless playback | ✅ | ❌ | Gap |
| Volume normalization | ✅ | ❌ | Gap |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
---
### Frontend Playback Code Duplication
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
**Symptoms**:
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
**Duplicated Code**:
```typescript
// These handlers are identical in both AudioPlayer and MiniPlayer:
handlePlayPause(), handleNext(), handlePrevious(),
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
// These derived states use identical logic:
displayMedia, displayIsPlaying, displayPosition, displayDuration
```
**Future Fix**:
1. Create `src/lib/utils/playbackUnits.ts`:
```typescript
export const TICKS_PER_SECOND = 10_000_000;
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
```
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
- Single source of truth for merged local/remote state
3. Simplify handler wrappers using a utility:
```typescript
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
```
**Impact**:
- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time
**Traces To**: DR-009
View File
-10227
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "", "description": "",
"type": "module", "type": "module",
"packageManager": "bun@1.3.5",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
@@ -18,7 +19,7 @@
"test:rust": "./scripts/test-rust.sh", "test:rust": "./scripts/test-rust.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 && npm install && npm run build", "android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
"android:deploy": "./scripts/deploy-android.sh", "android:deploy": "./scripts/deploy-android.sh",
"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",
+1 -1
View File
@@ -94,7 +94,7 @@ The traceability system is integrated with Gitea Actions CI/CD:
For details, see: For details, see:
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation - [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
- [TRACES Quick Reference](../traces-quick-ref.md) - Quick guide for adding TRACES - [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
## Utility Scripts ## Utility Scripts
+6 -2
View File
@@ -36,6 +36,10 @@ interface TracesData {
}; };
} }
// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
const BASE_DIR = path.resolve(import.meta.dir, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi; const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g; const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
@@ -45,7 +49,7 @@ function extractRequirementIds(tracesString: string): string[] {
} }
function getAllSourceFiles(): string[] { function getAllSourceFiles(): string[] {
const baseDir = "/home/dtourolle/Development/JellyTau"; const baseDir = BASE_DIR;
const patterns = ["src", "src-tauri/src"]; const patterns = ["src", "src-tauri/src"];
const files: string[] = []; const files: string[] = [];
@@ -101,7 +105,7 @@ function extractTraces(): TracesData {
}; };
let totalTraces = 0; let totalTraces = 0;
const baseDir = "/home/dtourolle/Development/JellyTau"; const baseDir = BASE_DIR;
const files = getAllSourceFiles(); const files = getAllSourceFiles();
-38
View File
@@ -1,38 +0,0 @@
[target.aarch64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.armv7-linux-androideabi]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.i686-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.x86_64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[env]
# Point to the NDK for the cc crate and other build scripts
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
# Set CC/CXX for each Android target (cc crate looks for these)
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
@@ -0,0 +1,86 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import com.dtourolle.jellytau.player.JellyTauPlayer
/**
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
*
* This class handles attaching and detaching the native ExoPlayer SurfaceView
* so that it renders video content behind the WebView.
*/
object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null
/**
* Attach the video SurfaceView to the Activity's content view.
*
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
*
* @param activity The Activity to attach the surface to
*/
fun attachVideoSurface(activity: Activity) {
try {
// Get the SurfaceView from JellyTauPlayer
val player = JellyTauPlayer.getInstance()
val surfaceView = player.getSurfaceView()
if (surfaceView == null) {
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
return
}
// Get the root content view
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
// Remove from parent if already attached elsewhere
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
// Configure layout params to fill the screen
val layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Add to content view at index 0 (behind WebView)
contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
} catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
}
}
/**
* Detach the video SurfaceView from the Activity's view hierarchy.
*
* @param activity The Activity to detach the surface from
*/
fun detachVideoSurface(activity: Activity) {
try {
attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView)
attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
}
} catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
}
}
/**
* Check if a video surface is currently attached.
*
* @return true if a surface is attached, false otherwise
*/
fun isVideoSurfaceAttached(): Boolean {
return attachedSurfaceView != null
}
}
+2
View File
@@ -1796,6 +1796,7 @@ mod tests {
// Test finding track 1 (should be index 0) // Test finding track 1 (should be index 0)
let index1 = tracks.iter().position(|t| t.id == "track1"); let index1 = tracks.iter().position(|t| t.id == "track1");
assert_eq!(index1, Some(0), "Track 1 should be at index 0"); assert_eq!(index1, Some(0), "Track 1 should be at index 0");
assert_eq!(tracks[0].name, "Song 1", "Track at index 0 should carry its name");
// Test finding track 3 (should be index 2) // Test finding track 3 (should be index 2)
let index3 = tracks.iter().position(|t| t.id == "track3"); let index3 = tracks.iter().position(|t| t.id == "track3");
@@ -1869,6 +1870,7 @@ mod tests {
// Verify all tracks are in correct order // Verify all tracks are in correct order
assert_eq!(tracks[0].id, "id1"); assert_eq!(tracks[0].id, "id1");
assert_eq!(tracks[0].name, "Track 1", "Sorted track should retain its name");
assert_eq!(tracks[1].id, "id2"); assert_eq!(tracks[1].id, "id2");
assert_eq!(tracks[2].id, "id3"); assert_eq!(tracks[2].id, "id3");
assert_eq!(tracks[3].id, "id4"); assert_eq!(tracks[3].id, "id4");
+16 -6
View File
@@ -52,6 +52,7 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
pub async fn repository_create( pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>, manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>, db: State<'_, crate::commands::storage::DatabaseWrapper>,
connectivity: State<'_, crate::commands::connectivity::ConnectivityMonitorWrapper>,
server_url: String, server_url: String,
user_id: String, user_id: String,
access_token: String, access_token: String,
@@ -68,9 +69,18 @@ pub async fn repository_create(
})?; })?;
debug!("[REPO] HTTP client created successfully"); debug!("[REPO] HTTP client created successfully");
// Create online repository // Grab a connectivity reporter so the online repository's server outcomes
// drive the reachability state the UI observes (source of truth for the
// offline/online banner). See docs/architecture/07-connectivity.md.
let connectivity_reporter = {
let monitor = connectivity.0.lock().await;
monitor.reporter()
};
// 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(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
@@ -499,16 +509,16 @@ mod tests {
#[test] #[test]
fn test_repository_manager_creation() { fn test_repository_manager_creation() {
let manager = RepositoryManager::new(); let manager = RepositoryManager::new();
// Should be able to create RepositoryManager without panicking // A freshly created manager holds no repositories
assert_eq!(std::mem::size_of::<RepositoryManager>() > 0, true); assert!(manager.get("any-handle").is_none());
} }
#[test] #[test]
fn test_repository_manager_wrapper_structure() { fn test_repository_manager_wrapper_structure() {
let manager = RepositoryManager::new(); let manager = RepositoryManager::new();
let wrapper = RepositoryManagerWrapper(manager); let wrapper = RepositoryManagerWrapper(manager);
// Verify wrapper holds the manager // The wrapper exposes the underlying manager, which starts empty
assert_eq!(std::mem::size_of::<RepositoryManagerWrapper>() > 0, true); assert!(wrapper.0.get("any-handle").is_none());
} }
#[test] #[test]
+351 -226
View File
@@ -1,16 +1,24 @@
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, Instant};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient; use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript) // Offline recovery probe interval.
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online // Reachability while online is driven by real repository traffic, so there is
// no online polling. While offline we probe quickly to detect the server
// returning even when no user traffic is flowing.
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
// Time-window debounce for declaring the server offline.
// A single dropped request must not trip the banner: we only flip to offline
// once network failures have persisted continuously for this window with no
// intervening success. Recovery (online) is instant on the first success.
const OFFLINE_CONFIRM_WINDOW: Duration = Duration::from_secs(5);
/// Connectivity status /// Connectivity status
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)] #[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -45,210 +53,115 @@ struct ConnectivityChangeEvent {
is_reachable: bool, is_reachable: bool,
} }
/// Connectivity monitor for tracking server reachability /// Shared reachability state and transition logic.
pub struct ConnectivityMonitor { ///
server_url: Arc<RwLock<Option<String>>>, /// This is the single place that mutates reachability and emits events. It is
http_client: Arc<HttpClient>, /// cheap to clone (all fields are `Arc`/`Option`) and is shared by:
/// - the `ConnectivityMonitor` (commands, offline recovery probe), and
/// - `OnlineRepository`, which reports the outcome of every server request.
///
/// Reachability is therefore driven by real traffic; the probe only fills the
/// gap while offline.
#[derive(Clone)]
pub struct ConnectivityReporter {
status: Arc<RwLock<ConnectivityStatus>>, status: Arc<RwLock<ConnectivityStatus>>,
is_monitoring: Arc<AtomicBool>, /// Timestamp of the first network failure in the current failure streak.
/// Used to debounce the transition to offline (see `OFFLINE_CONFIRM_WINDOW`).
first_failure_at: Arc<RwLock<Option<Instant>>>,
app_handle: Option<AppHandle>, app_handle: Option<AppHandle>,
} }
impl ConnectivityMonitor { impl ConnectivityReporter {
/// Create a new connectivity monitor fn new(status: Arc<RwLock<ConnectivityStatus>>, app_handle: Option<AppHandle>) -> Self {
pub fn new(http_client: HttpClient) -> Self {
Self { Self {
server_url: Arc::new(RwLock::new(None)), status,
http_client: Arc::new(http_client), first_failure_at: Arc::new(RwLock::new(None)),
status: Arc::new(RwLock::new(ConnectivityStatus::default())), app_handle,
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
} }
} }
/// Set the Tauri app handle for event emission /// Current reachability as seen by this reporter (shared with the monitor
pub fn set_app_handle(&mut self, app_handle: AppHandle) { /// and the UI). Useful for callers that want to branch on connectivity.
self.app_handle = Some(app_handle); pub async fn is_reachable(&self) -> bool {
self.status.read().await.is_server_reachable
} }
/// Update the server URL /// Test-only: force the reporter into the offline state without going through
pub async fn set_server_url(&self, url: String) { /// the debounce, so other modules' tests can set up an "offline" precondition.
log::info!("[ConnectivityMonitor] Setting server URL: {}", url); #[cfg(test)]
let mut server_url = self.server_url.write().await; pub async fn mark_unreachable_for_test(&self) {
*server_url = Some(url.clone()); self.apply_probe_result(false, Some("forced offline (test)".to_string()))
drop(server_url); .await;
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
} }
/// Get current connectivity status /// Report that a real server request succeeded (or that the server answered
pub async fn get_status(&self) -> ConnectivityStatus { /// at all, e.g. with 401/404/5xx). The server is up — recover instantly.
self.status.read().await.clone() pub async fn report_success(&self) {
*self.first_failure_at.write().await = None;
self.set_reachable(true, None).await;
} }
/// Check if the Jellyfin server is reachable /// Report that a real server request failed with a network-level error
pub async fn check_reachability(&self) -> bool { /// (connection refused, timeout, DNS). Subject to the time-window debounce:
// Mark as checking /// we only flip to offline once failures have persisted for
{ /// `OFFLINE_CONFIRM_WINDOW` with no intervening success.
let mut status = self.status.write().await; pub async fn report_network_failure(&self, error: Option<String>) {
status.is_checking = true; // If already offline, nothing to debounce.
if !self.status.read().await.is_server_reachable {
return;
} }
let server_url = self.server_url.read().await.clone(); let now = Instant::now();
let streak_start = {
if server_url.is_none() { let mut first = self.first_failure_at.write().await;
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured"); *first.get_or_insert(now)
let mut status = self.status.write().await;
status.is_server_reachable = false;
status.connection_error = Some("No server URL configured".to_string());
status.is_checking = false;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
}; };
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url); if now.duration_since(streak_start) >= OFFLINE_CONFIRM_WINDOW {
log::warn!(
// Attempt to ping the server "[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
let is_reachable = self.http_client.ping(&ping_url).await; OFFLINE_CONFIRM_WINDOW
log::debug!(
"[ConnectivityMonitor] Ping result: {} (was: {})",
if is_reachable { "SUCCESS" } else { "FAILED" },
if was_reachable { "reachable" } else { "unreachable" }
); );
self.set_reachable(false, error).await;
} else {
log::debug!(
"[ConnectivityMonitor] Network failure within debounce window; not yet offline"
);
}
}
// Update status /// Apply a deliberate reachability probe result (offline recovery probe or a
{ /// manual check). Unlike `report_network_failure`, a probe is an explicit
/// reachability test, so its result is applied immediately without debounce.
async fn apply_probe_result(&self, is_reachable: bool, error: Option<String>) {
if is_reachable {
*self.first_failure_at.write().await = None;
}
self.set_reachable(is_reachable, error).await;
}
/// Core transition: update status and emit events only on an actual change.
async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
let was_reachable = {
let mut status = self.status.write().await; let mut status = self.status.write().await;
let was = status.is_server_reachable;
status.is_server_reachable = is_reachable; status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339()); status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable { status.connection_error = if is_reachable {
None None
} else { } else {
Some("Server unreachable".to_string()) Some(error.unwrap_or_else(|| "Server unreachable".to_string()))
}; };
status.is_checking = false; status.is_checking = false;
} was
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
/// Mark server as reachable (called after successful API call)
pub async fn mark_reachable(&self) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = true;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = None;
drop(status);
if !was_reachable {
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
self.emit_connectivity_change(true).await;
self.emit_server_reconnected().await;
}
}
/// Mark server as unreachable (called after failed API call)
pub async fn mark_unreachable(&self, error: Option<String>) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = false;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
let error_msg = status.connection_error.clone().unwrap_or_default();
drop(status);
if was_reachable {
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
self.emit_connectivity_change(false).await;
}
}
/// Start monitoring connectivity with adaptive polling
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
// Perform immediate check before starting background task
// This ensures we get an accurate state right away instead of assuming offline
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
// Clone Arc references for the background task
let status = Arc::clone(&self.status);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let self_clone = Arc::new(ConnectivityMonitorHandle {
server_url,
http_client,
status,
app_handle: self.app_handle.clone(),
});
// Spawn background monitoring task
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
// Determine interval based on current reachability
let interval_ms = {
let status = self_clone.status.read().await;
if status.is_server_reachable {
AUTO_CHECK_INTERVAL_MS
} else {
RETRY_CHECK_INTERVAL_MS
}
}; };
// Wait for the interval if is_reachable != was_reachable {
tokio::time::sleep(Duration::from_millis(interval_ms)).await; self.emit_connectivity_change(is_reachable).await;
if is_reachable {
// Check if still monitoring self.emit_server_reconnected().await;
if !is_monitoring.load(Ordering::SeqCst) {
break;
} }
// Perform connectivity check
let _ = self_clone.check_reachability().await;
} }
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
} }
/// Emit connectivity change event to frontend /// Emit connectivity change event to frontend
@@ -275,87 +188,183 @@ impl ConnectivityMonitor {
} }
} }
/// Handle for the background monitoring task /// Connectivity monitor for tracking server reachability.
struct ConnectivityMonitorHandle { ///
/// Reachability is driven primarily by real repository traffic via the shared
/// [`ConnectivityReporter`]. The monitor itself only runs an offline recovery
/// probe (see `start_monitoring`) and serves the connectivity Tauri commands.
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>, server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>, http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>, reporter: ConnectivityReporter,
app_handle: Option<AppHandle>, is_monitoring: Arc<AtomicBool>,
}
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
let status = Arc::new(RwLock::new(ConnectivityStatus::default()));
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
reporter: ConnectivityReporter::new(status, None),
is_monitoring: Arc::new(AtomicBool::new(false)),
}
}
/// Set the Tauri app handle for event emission.
/// Must be called before the reporter is shared with the repository.
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.reporter.app_handle = Some(app_handle);
}
/// Get a cheap, cloneable reporter so the repository can feed server
/// outcomes into the same reachability state the UI observes.
pub fn reporter(&self) -> ConnectivityReporter {
self.reporter.clone()
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
*self.server_url.write().await = Some(url);
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.reporter.status.read().await.clone()
}
/// Deliberately probe the server's reachability (manual check / recovery probe).
/// The result is applied immediately (no debounce) since this is an explicit test.
pub async fn check_reachability(&self) -> bool {
{
let mut status = self.reporter.status.write().await;
status.is_checking = true;
} }
impl ConnectivityMonitorHandle {
async fn check_reachability(&self) -> bool {
let server_url = self.server_url.read().await.clone(); let server_url = self.server_url.read().await.clone();
if server_url.is_none() { let Some(url) = server_url else {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
self.reporter
.apply_probe_result(false, Some("No server URL configured".to_string()))
.await;
return false; return false;
} };
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url); let ping_url = format!("{}/System/Info/Public", url);
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await; let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {}",
if is_reachable { "SUCCESS" } else { "FAILED" }
);
// Update status self.reporter.apply_probe_result(is_reachable, None).await;
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable is_reachable
} }
async fn emit_connectivity_change(&self, is_reachable: bool) { /// Mark server as reachable (called after successful API call / login)
if let Some(app_handle) = &self.app_handle { pub async fn mark_reachable(&self) {
let event = ConnectivityChangeEvent { is_reachable }; self.reporter.report_success().await;
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
} }
/// Mark server as unreachable directly.
///
/// Used by deliberate signals (e.g. a failed login/connect) where the caller
/// knows the server is unreachable now. Repository traffic should prefer
/// `reporter().report_network_failure()` so the debounce applies.
pub async fn mark_unreachable(&self, error: Option<String>) {
self.reporter.apply_probe_result(false, error).await;
}
/// Start the offline recovery probe.
///
/// While **online**, reachability is kept fresh by real traffic, so the probe
/// idles. While **offline**, it polls `/System/Info/Public` every
/// `RETRY_CHECK_INTERVAL_MS` to detect the server returning even when no user
/// traffic is flowing.
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring (offline recovery probe)");
// Perform an immediate check so startup reflects reality quickly.
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let reporter = self.reporter.clone();
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
tokio::time::sleep(Duration::from_millis(RETRY_CHECK_INTERVAL_MS)).await;
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Only probe while offline — real traffic is the signal when online.
if reporter.status.read().await.is_server_reachable {
continue;
}
let Some(url) = server_url.read().await.clone() else {
continue;
};
let ping_url = format!("{}/System/Info/Public", url);
let is_reachable = http_client.ping(&ping_url).await;
// Probe only ever recovers us to online; a failed probe leaves us
// offline without re-emitting (no change).
if is_reachable {
reporter.apply_probe_result(true, None).await;
} }
} }
async fn emit_server_reconnected(&self) { log::info!("[ConnectivityMonitor] Stopped monitoring");
if let Some(app_handle) = &self.app_handle { });
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
}
} }
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::jellyfin::http_client::HttpConfig;
/// Build a reporter backed by a fresh (optimistic) status, with no app handle.
/// Event emission is a no-op without a handle, which is exactly what we want
/// for unit-testing the reachability state transitions.
fn test_reporter() -> ConnectivityReporter {
ConnectivityReporter::new(Arc::new(RwLock::new(ConnectivityStatus::default())), None)
}
async fn is_reachable(reporter: &ConnectivityReporter) -> bool {
reporter.status.read().await.is_server_reachable
}
#[test] #[test]
fn test_intervals() { fn test_intervals() {
// Verify intervals match TypeScript // Offline recovery probe interval (online has no polling).
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000); assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
assert_eq!(OFFLINE_CONFIRM_WINDOW, Duration::from_secs(5));
} }
#[tokio::test] #[tokio::test]
@@ -367,4 +376,120 @@ mod tests {
assert!(status.connection_error.is_none()); assert!(status.connection_error.is_none());
assert!(!status.is_checking); assert!(!status.is_checking);
} }
/// A single (or brief) network failure must NOT flip the app offline:
/// the time-window debounce keeps us online until the failure persists.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_single_network_failure_does_not_go_offline() {
let reporter = test_reporter();
assert!(is_reachable(&reporter).await, "starts online");
reporter
.report_network_failure(Some("timeout".to_string()))
.await;
assert!(
is_reachable(&reporter).await,
"one network failure within the debounce window stays online"
);
// But the failure streak is now being tracked.
assert!(reporter.first_failure_at.read().await.is_some());
}
/// Once failures persist past OFFLINE_CONFIRM_WINDOW, we flip offline.
/// We simulate elapsed time by backdating the streak start.
///
/// @req-test: UR-002 - Access media when online or offline
#[tokio::test]
async fn test_sustained_network_failure_goes_offline() {
let reporter = test_reporter();
// First failure starts the streak.
reporter.report_network_failure(None).await;
assert!(is_reachable(&reporter).await);
// Backdate the streak start to before the window.
{
let mut first = reporter.first_failure_at.write().await;
*first = Some(Instant::now() - OFFLINE_CONFIRM_WINDOW - Duration::from_secs(1));
}
// Next failure now exceeds the window → offline.
reporter
.report_network_failure(Some("connection refused".to_string()))
.await;
assert!(
!is_reachable(&reporter).await,
"sustained network failure flips to offline"
);
}
/// A success during a failure streak clears the streak and keeps us online —
/// recovery is instant and never trips the banner.
#[tokio::test]
async fn test_success_clears_failure_streak() {
let reporter = test_reporter();
reporter.report_network_failure(None).await;
assert!(reporter.first_failure_at.read().await.is_some());
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
assert!(
reporter.first_failure_at.read().await.is_none(),
"success resets the debounce streak"
);
}
/// First success after being offline recovers instantly (no debounce on the
/// way back up).
#[tokio::test]
async fn test_recovery_is_instant() {
let reporter = test_reporter();
// Force offline.
reporter.apply_probe_result(false, Some("down".to_string())).await;
assert!(!is_reachable(&reporter).await);
// A single success brings us straight back online.
reporter.report_success().await;
assert!(is_reachable(&reporter).await);
let status = reporter.status.read().await;
assert!(status.connection_error.is_none());
}
/// Server-answered errors (401/404/5xx) are reported via report_success
/// by the repository, because the server is demonstrably reachable. This
/// test documents that contract: report_success means "server is up".
#[tokio::test]
async fn test_server_answered_error_counts_as_reachable() {
let reporter = test_reporter();
// Simulate being offline, then the server answers (even with an error).
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Repository maps Authentication/NotFound/Server errors to report_success.
reporter.report_success().await;
assert!(
is_reachable(&reporter).await,
"a server that answers (even with 4xx/5xx) is reachable"
);
}
/// report_network_failure is a no-op once already offline (nothing to debounce,
/// no duplicate events).
#[tokio::test]
async fn test_network_failure_noop_when_already_offline() {
let reporter = test_reporter();
reporter.apply_probe_result(false, None).await;
assert!(!is_reachable(&reporter).await);
// Should not panic or change state.
reporter.report_network_failure(Some("still down".to_string())).await;
assert!(!is_reachable(&reporter).await);
}
} }
-1
View File
@@ -120,7 +120,6 @@ pub struct DownloadTask {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::player::MediaType;
#[test] #[test]
fn test_download_manager_set_max_concurrent() { fn test_download_manager_set_max_concurrent() {
+3
View File
@@ -101,6 +101,7 @@ use commands::{
repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped,
repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite,
repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_person, repository_get_items_by_person, repository_get_similar_items,
repository_get_subtitle_url, repository_get_video_download_url,
// Playlist commands // Playlist commands
playlist_create, playlist_delete, playlist_rename, playlist_get_items, playlist_create, playlist_delete, playlist_rename, playlist_get_items,
playlist_add_items, playlist_remove_items, playlist_move_item, playlist_add_items, playlist_remove_items, playlist_move_item,
@@ -565,6 +566,8 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_person, repository_get_person,
repository_get_items_by_person, repository_get_items_by_person,
repository_get_similar_items, repository_get_similar_items,
repository_get_subtitle_url,
repository_get_video_download_url,
// Playlist commands // Playlist commands
playlist_create, playlist_create,
playlist_delete, playlist_delete,
-4
View File
@@ -172,10 +172,6 @@ mod tests {
pub fn events(&self) -> Vec<PlayerStatusEvent> { pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone() self.events.lock_safe().clone()
} }
pub fn clear(&self) {
self.events.lock_safe().clear();
}
} }
impl PlayerEventEmitter for TestEventEmitter { impl PlayerEventEmitter for TestEventEmitter {
+1
View File
@@ -217,6 +217,7 @@ impl PlayerController {
/// Used on platforms where video is rendered outside the native backend /// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the /// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it. /// item, but MPV must not start a redundant decode for it.
#[cfg(target_os = "linux")]
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> { pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!("[PlayerController] set_current_item (no backend load): {}", item.title); debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
+1 -1
View File
@@ -5,7 +5,7 @@
* beyond individual playback states. Enables persistent UI (miniplayer) and * beyond individual playback states. Enables persistent UI (miniplayer) and
* proper transitions between content types. * proper transitions between content types.
* *
* See software-architecture.md Section 2.1 for state machine diagram. * See docs/architecture/01-rust-backend.md for the state machine diagram.
*/ */
use log::info; use log::info;
+212 -7
View File
@@ -7,6 +7,7 @@ use log::{debug, error, info};
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient; use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*}; use super::{MediaRepository, types::*};
@@ -16,6 +17,10 @@ pub struct OnlineRepository {
server_url: String, server_url: String,
user_id: String, user_id: String,
access_token: String, access_token: String,
/// Reports the outcome of every server request to the connectivity monitor.
/// This is the source of truth for the offline/online banner. `None` in
/// tests / contexts where connectivity tracking isn't wired up.
connectivity: Option<ConnectivityReporter>,
} }
impl OnlineRepository { impl OnlineRepository {
@@ -30,6 +35,44 @@ impl OnlineRepository {
server_url, server_url,
user_id, user_id,
access_token, access_token,
connectivity: None,
}
}
/// Attach a connectivity reporter so server outcomes drive the reachability
/// state observed by the UI. See `report_outcome`.
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
self.connectivity = Some(reporter);
self
}
/// Feed a request outcome into the connectivity monitor.
///
/// Classification (matches docs/architecture/07-connectivity.md):
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
/// so it is reachable → `report_success` (instant recovery).
/// - `Network` → connection-level failure → `report_network_failure`
/// (subject to the time-window debounce before going offline).
/// - `Database` → not a server signal → ignored.
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
let Some(reporter) = &self.connectivity else {
return;
};
match result {
Ok(_)
| Err(RepoError::Authentication { .. })
| Err(RepoError::NotFound { .. })
| Err(RepoError::Server { .. }) => {
reporter.report_success().await;
}
Err(RepoError::Network { message }) => {
reporter.report_network_failure(Some(message.clone())).await;
}
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
// Local-side errors (cache failure / already-offline) — not a
// statement about the server's reachability, so ignore them.
}
} }
} }
@@ -63,6 +106,12 @@ impl OnlineRepository {
/// Make authenticated GET request /// Make authenticated GET request
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> { async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let result = self.get_json_inner(endpoint).await;
self.report_outcome(&result).await;
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url) let request = self.http_client.client.get(&url)
@@ -110,6 +159,12 @@ impl OnlineRepository {
/// Make authenticated POST request /// Make authenticated POST request
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> { async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let result = self.post_json_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url) let request = self.http_client.client.post(&url)
@@ -145,6 +200,16 @@ impl OnlineRepository {
&self, &self,
endpoint: &str, endpoint: &str,
body: &T, body: &T,
) -> Result<R, RepoError> {
let result = self.post_json_response_inner(endpoint, body).await;
self.report_outcome(&result).await;
result
}
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
body: &T,
) -> Result<R, RepoError> { ) -> Result<R, RepoError> {
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
@@ -193,9 +258,16 @@ impl OnlineRepository {
}) })
} }
/// Get a video stream URL for playback with optional seeking support. /// Get a video stream URL for playback at an arbitrary position (resume,
/// For direct streams, uses /Videos/{id}/stream.mp4 with transcoding to support seeking. /// transcoded seeking, audio-track switching).
/// For transcoded streams (HEVC/10-bit), includes StartTimeTicks parameter. ///
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
pub async fn get_video_stream_url( pub async fn get_video_stream_url(
&self, &self,
item_id: &str, item_id: &str,
@@ -210,12 +282,11 @@ impl OnlineRepository {
// Use provided audio stream index, or default to 0 // Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string(); let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build URL with progressive MP4 transcoding for seeking support // Build an HLS transcode URL. VideoCodec lists h264 first so the server
// This works for both direct streams and HEVC content // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![ let mut params = vec![
("api_key", self.access_token.clone()), ("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()), ("DeviceId", "jellytau-tauri".to_string()),
("Container", "mp4".to_string()),
("VideoCodec", "h264".to_string()), ("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()), ("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index), ("AudioStreamIndex", audio_index),
@@ -223,6 +294,9 @@ impl OnlineRepository {
("VideoBitrate", "18000000".to_string()), ("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()), ("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()), ("TranscodingMaxAudioChannels", "2".to_string()),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
]; ];
if let Some(source_id) = media_source_id { if let Some(source_id) = media_source_id {
@@ -241,7 +315,7 @@ impl OnlineRepository {
.join("&"); .join("&");
let url = format!( let url = format!(
"{}/Videos/{}/stream.mp4?{}", "{}/Videos/{}/master.m3u8?{}",
self.server_url, item_id, query self.server_url, item_id, query
); );
@@ -1147,6 +1221,7 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id); let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint); let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self.http_client.client.delete(&url) let request = self.http_client.client.delete(&url)
.header("X-Emby-Authorization", self.auth_header()) .header("X-Emby-Authorization", self.auth_header())
.build() .build()
@@ -1165,6 +1240,11 @@ impl MediaRepository for OnlineRepository {
Ok(()) Ok(())
} }
.await;
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> { async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id); let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
@@ -1388,6 +1468,91 @@ mod tests {
) )
} }
/// Build a repository wired to a real ConnectivityReporter so we can assert
/// how `report_outcome` classifies each `RepoError` into reachability.
/// (No app handle → event emission is a harmless no-op.)
fn create_test_repository_with_connectivity(
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
.expect("Failed to create HTTP client for monitor");
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
let reporter = monitor.reporter();
let repo = create_test_repository().with_connectivity(reporter.clone());
(repo, reporter)
}
/// `report_outcome` is the seam between repository traffic and the
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
///
/// @req-test: UR-002 - Access media when online or offline
/// @req-test: DR-013 - Repository pattern for online/offline data access
#[tokio::test]
async fn test_report_outcome_classifies_server_answered_as_reachable() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a server that answers should be reported reachable"
);
}
// Ok should also report reachable.
reporter.mark_unreachable_for_test().await;
let ok: Result<(), RepoError> = Ok(());
repo.report_outcome(&ok).await;
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
}
/// Local-side errors must NOT flip reachability — they say nothing about the
/// server.
#[tokio::test]
async fn test_report_outcome_ignores_local_errors() {
let (repo, reporter) = create_test_repository_with_connectivity();
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
!reporter.is_reachable().await,
"local-side error must not change reachability"
);
}
}
/// A network error routes through the debounced path. A single failure stays
/// online (debounce window not yet elapsed).
#[tokio::test]
async fn test_report_outcome_network_error_is_debounced() {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
repo.report_outcome(&result).await;
assert!(
reporter.is_reachable().await,
"a single network failure stays online (debounced)"
);
}
#[tokio::test] #[tokio::test]
async fn test_get_audio_stream_url_formats_correctly() { async fn test_get_audio_stream_url_formats_correctly() {
let repo = create_test_repository(); let repo = create_test_repository();
@@ -1403,6 +1568,46 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_get_video_stream_url_returns_hls_with_position() {
// Transcoded video resume/seek must produce an HLS master playlist with
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
// for HEVC sources). See get_video_stream_url docs.
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
"expected HLS master playlist, got: {url}"
);
assert!(url.contains("VideoCodec=h264"));
assert!(url.contains("MediaSourceId=source-1"));
assert!(url.contains("AudioStreamIndex=1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
assert!(!url.contains("stream.mp4"));
}
#[tokio::test]
async fn test_get_video_stream_url_omits_position_when_absent() {
let repo = create_test_repository();
let url = repo
.get_video_stream_url("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test] #[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() { async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository(); let repo = create_test_repository();
-1
View File
@@ -344,7 +344,6 @@ mod tests {
use super::*; use super::*;
use crate::storage::db_service::RusqliteService; use crate::storage::db_service::RusqliteService;
use rusqlite::Connection; use rusqlite::Connection;
use std::io::Write;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tempfile::TempDir; use tempfile::TempDir;
+23 -9
View File
@@ -10,6 +10,7 @@ import {
playNextEpisode, playNextEpisode,
type AutoplaySettings, type AutoplaySettings,
} from "./autoplay"; } from "./autoplay";
import type { PlayItemRequest } from "./bindings";
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: any) => { invoke: vi.fn(async (command: string, args?: any) => {
@@ -33,6 +34,23 @@ vi.mock("@tauri-apps/api/core", () => ({
}), }),
})); }));
// setAutoplaySettings sources the user id from the auth store
vi.mock("$lib/stores/auth", () => ({
auth: { getUserId: () => "user-1" },
}));
// Minimal valid PlayItemRequest for playNextEpisode tests
function makeItem(overrides: Partial<PlayItemRequest> = {}): PlayItemRequest {
return {
id: "item-123",
title: "Episode 1",
streamUrl: "http://example/stream",
videoCodec: "h264",
needsTranscoding: false,
...overrides,
};
}
describe("autoplay API", () => { describe("autoplay API", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -98,7 +116,7 @@ describe("autoplay API", () => {
(c) => c[0] === "player_set_autoplay_settings" (c) => c[0] === "player_set_autoplay_settings"
); );
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual({ settings }); expect(call![1]).toEqual({ userId: "user-1", settings });
}); });
it("should support different countdown values", async () => { it("should support different countdown values", async () => {
@@ -130,11 +148,7 @@ describe("autoplay API", () => {
describe("playNextEpisode", () => { describe("playNextEpisode", () => {
it("should play next episode with item", async () => { it("should play next episode with item", async () => {
const mockItem = { const mockItem = makeItem();
id: "item-123",
name: "Episode 1",
seriesId: "series-456",
};
await playNextEpisode(mockItem); await playNextEpisode(mockItem);
@@ -150,9 +164,9 @@ describe("autoplay API", () => {
it("should handle different item types", async () => { it("should handle different item types", async () => {
const items = [ const items = [
{ id: "1", name: "Episode 1" }, makeItem({ id: "1", title: "Episode 1" }),
{ id: "2", name: "Episode 2", seasonNumber: 1 }, makeItem({ id: "2", title: "Episode 2" }),
{ id: "3", name: "Episode 3", episodeNumber: 5 }, makeItem({ id: "3", title: "Episode 3" }),
]; ];
for (const item of items) { for (const item of items) {
+9 -11
View File
@@ -4,28 +4,26 @@
* Functions to control autoplay in the backend. * Functions to control autoplay in the backend.
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { PlayItemRequest, AutoplaySettings } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
export interface AutoplaySettings { export type { AutoplaySettings };
enabled: boolean;
countdownSeconds: number;
maxEpisodes: number;
}
export async function getAutoplaySettings(): Promise<AutoplaySettings> { export async function getAutoplaySettings(): Promise<AutoplaySettings> {
return invoke("player_get_autoplay_settings"); return commands.playerGetAutoplaySettings();
} }
export async function setAutoplaySettings( export async function setAutoplaySettings(
settings: AutoplaySettings settings: AutoplaySettings
): Promise<AutoplaySettings> { ): Promise<AutoplaySettings> {
return invoke("player_set_autoplay_settings", { settings }); return commands.playerSetAutoplaySettings(auth.getUserId() ?? "", settings);
} }
export async function cancelAutoplayCountdown(): Promise<void> { export async function cancelAutoplayCountdown(): Promise<void> {
return invoke("player_cancel_autoplay_countdown"); await commands.playerCancelAutoplayCountdown();
} }
export async function playNextEpisode(item: any): Promise<void> { export async function playNextEpisode(item: PlayItemRequest): Promise<void> {
return invoke("player_play_next_episode", { item }); await commands.playerPlayNextEpisode(item);
} }
+12
View File
@@ -1121,6 +1121,18 @@ async repositoryGetItemsByPerson(handle: string, personId: string, options: GetI
async repositoryGetSimilarItems(handle: string, itemId: string, limit: number | null) : Promise<SearchResult> { async repositoryGetSimilarItems(handle: string, itemId: string, limit: number | null) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_get_similar_items", { handle, itemId, limit }); return await TAURI_INVOKE("repository_get_similar_items", { handle, itemId, limit });
}, },
/**
* Get subtitle URL for a media item
*/
async repositoryGetSubtitleUrl(handle: string, itemId: string, mediaSourceId: string, streamIndex: number, format: string) : Promise<string> {
return await TAURI_INVOKE("repository_get_subtitle_url", { handle, itemId, mediaSourceId, streamIndex, format });
},
/**
* Get video download URL with quality preset
*/
async repositoryGetVideoDownloadUrl(handle: string, itemId: string, quality: string, mediaSourceId: string | null) : Promise<string> {
return await TAURI_INVOKE("repository_get_video_download_url", { handle, itemId, quality, mediaSourceId });
},
/** /**
* Create a new playlist * Create a new playlist
*/ */
+39 -157
View File
@@ -2,9 +2,8 @@
// All API calls go through Tauri commands in src-tauri/src/commands/repository.rs // All API calls go through Tauri commands in src-tauri/src/commands/repository.rs
// NO direct HTTP calls - everything routes through Rust backend // NO direct HTTP calls - everything routes through Rust backend
import { invoke } from "@tauri-apps/api/core"; import { commands } from "./bindings";
import type { QualityPreset } from "./quality-presets"; import type { QualityPreset } from "./quality-presets";
import { QUALITY_PRESETS } from "./quality-presets";
import type { import type {
Library, Library,
MediaItem, MediaItem,
@@ -39,12 +38,7 @@ export class RepositoryClient {
serverId: string serverId: string
): Promise<string> { ): Promise<string> {
console.log("[RepositoryClient] Creating Rust repository..."); console.log("[RepositoryClient] Creating Rust repository...");
this.handle = await invoke<string>("repository_create", { this.handle = await commands.repositoryCreate(serverUrl, userId, accessToken, serverId);
serverUrl,
userId,
accessToken,
serverId,
});
// Store for URL construction // Store for URL construction
this._serverUrl = serverUrl; this._serverUrl = serverUrl;
@@ -60,7 +54,7 @@ export class RepositoryClient {
*/ */
async destroy(): Promise<void> { async destroy(): Promise<void> {
if (this.handle) { if (this.handle) {
await invoke("repository_destroy", { handle: this.handle }); await commands.repositoryDestroy(this.handle);
this.handle = null; this.handle = null;
this._serverUrl = null; this._serverUrl = null;
this._accessToken = null; this._accessToken = null;
@@ -84,119 +78,67 @@ export class RepositoryClient {
// ===== Library Methods (all via Rust) ===== // ===== Library Methods (all via Rust) =====
async getLibraries(): Promise<Library[]> { async getLibraries(): Promise<Library[]> {
return invoke<Library[]>("repository_get_libraries", { return commands.repositoryGetLibraries(this.ensureHandle());
handle: this.ensureHandle(),
});
} }
async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> { async getItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items", { return commands.repositoryGetItems(this.ensureHandle(), parentId, options ?? null);
handle: this.ensureHandle(),
parentId,
options: options ?? null,
});
} }
async getItem(itemId: string): Promise<MediaItem> { async getItem(itemId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_item", { return commands.repositoryGetItem(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> { async getLatestItems(parentId: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_latest_items", { return commands.repositoryGetLatestItems(this.ensureHandle(), parentId, limit ?? null);
handle: this.ensureHandle(),
parentId,
limit: limit ?? null,
});
} }
async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> { async getResumeItems(parentId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_items", { return commands.repositoryGetResumeItems(this.ensureHandle(), parentId ?? null, limit ?? null);
handle: this.ensureHandle(),
parentId: parentId ?? null,
limit: limit ?? null,
});
} }
async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> { async getNextUpEpisodes(seriesId?: string, limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_next_up_episodes", { return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
handle: this.ensureHandle(),
seriesId: seriesId ?? null,
limit: limit ?? null,
});
} }
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> { async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_recently_played_audio", { return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
handle: this.ensureHandle(),
limit: limit ?? null,
});
} }
async getResumeMovies(limit?: number): Promise<MediaItem[]> { async getResumeMovies(limit?: number): Promise<MediaItem[]> {
return invoke<MediaItem[]>("repository_get_resume_movies", { return commands.repositoryGetResumeMovies(this.ensureHandle(), limit ?? null);
handle: this.ensureHandle(),
limit: limit ?? null,
});
} }
async getGenres(parentId?: string): Promise<Genre[]> { async getGenres(parentId?: string): Promise<Genre[]> {
return invoke<Genre[]>("repository_get_genres", { return commands.repositoryGetGenres(this.ensureHandle(), parentId ?? null);
handle: this.ensureHandle(),
parentId: parentId ?? null,
});
} }
async search(query: string, options?: SearchOptions): Promise<SearchResult> { async search(query: string, options?: SearchOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_search", { return commands.repositorySearch(this.ensureHandle(), query, options ?? null);
handle: this.ensureHandle(),
query,
options: options ?? null,
});
} }
// ===== Playback Methods (all via Rust) ===== // ===== Playback Methods (all via Rust) =====
async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> { async getPlaybackInfo(itemId: string): Promise<PlaybackInfo> {
return invoke<PlaybackInfo>("repository_get_playback_info", { return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_start", { await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_progress", { await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> { async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
return invoke("repository_report_playback_stopped", { await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
handle: this.ensureHandle(),
itemId,
positionTicks,
});
} }
// ===== Stream URL Methods (via Rust) ===== // ===== Stream URL Methods (via Rust) =====
async getAudioStreamUrl(itemId: string): Promise<string> { async getAudioStreamUrl(itemId: string): Promise<string> {
return invoke<string>("repository_get_audio_stream_url", { return commands.repositoryGetAudioStreamUrl(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async getVideoStreamUrl( async getVideoStreamUrl(
@@ -205,13 +147,13 @@ export class RepositoryClient {
startTimeSeconds?: number, startTimeSeconds?: number,
audioStreamIndex?: number audioStreamIndex?: number
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_video_stream_url", { return commands.repositoryGetVideoStreamUrl(
handle: this.ensureHandle(), this.ensureHandle(),
itemId, itemId,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null,
startTimeSeconds: startTimeSeconds ?? null, startTimeSeconds ?? null,
audioStreamIndex: audioStreamIndex ?? null, audioStreamIndex ?? null
}); );
} }
// ===== URL Construction Methods (sync, no server call) ===== // ===== URL Construction Methods (sync, no server call) =====
@@ -221,12 +163,7 @@ export class RepositoryClient {
* The Rust backend constructs and returns the URL with proper credentials handling * The Rust backend constructs and returns the URL with proper credentials handling
*/ */
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> { async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
return invoke<string>("repository_get_image_url", { return commands.repositoryGetImageUrl(this.ensureHandle(), itemId, imageType, options ?? null);
handle: this.ensureHandle(),
itemId,
imageType,
options: options ?? null,
});
} }
/** /**
@@ -239,13 +176,7 @@ export class RepositoryClient {
streamIndex: number, streamIndex: number,
format: string = "vtt" format: string = "vtt"
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_subtitle_url", { return commands.repositoryGetSubtitleUrl(this.ensureHandle(), itemId, mediaSourceId, streamIndex, format);
handle: this.ensureHandle(),
itemId,
mediaSourceId,
streamIndex,
format,
});
} }
/** /**
@@ -258,110 +189,61 @@ export class RepositoryClient {
quality: QualityPreset = "original", quality: QualityPreset = "original",
mediaSourceId?: string mediaSourceId?: string
): Promise<string> { ): Promise<string> {
return invoke<string>("repository_get_video_download_url", { return commands.repositoryGetVideoDownloadUrl(this.ensureHandle(), itemId, quality, mediaSourceId ?? null);
handle: this.ensureHandle(),
itemId,
quality,
mediaSourceId: mediaSourceId ?? null,
});
} }
// ===== Favorite Methods (via Rust) ===== // ===== Favorite Methods (via Rust) =====
async markFavorite(itemId: string): Promise<void> { async markFavorite(itemId: string): Promise<void> {
return invoke("repository_mark_favorite", { await commands.repositoryMarkFavorite(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
async unmarkFavorite(itemId: string): Promise<void> { async unmarkFavorite(itemId: string): Promise<void> {
return invoke("repository_unmark_favorite", { await commands.repositoryUnmarkFavorite(this.ensureHandle(), itemId);
handle: this.ensureHandle(),
itemId,
});
} }
// ===== Person Methods (via Rust) ===== // ===== Person Methods (via Rust) =====
async getPerson(personId: string): Promise<MediaItem> { async getPerson(personId: string): Promise<MediaItem> {
return invoke<MediaItem>("repository_get_person", { return commands.repositoryGetPerson(this.ensureHandle(), personId);
handle: this.ensureHandle(),
personId,
});
} }
async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> { async getItemsByPerson(personId: string, options?: GetItemsOptions): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_items_by_person", { return commands.repositoryGetItemsByPerson(this.ensureHandle(), personId, options ?? null);
handle: this.ensureHandle(),
personId,
options: options ?? null,
});
} }
async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> { async getSimilarItems(itemId: string, limit?: number): Promise<SearchResult> {
return invoke<SearchResult>("repository_get_similar_items", { return commands.repositoryGetSimilarItems(this.ensureHandle(), itemId, limit ?? null);
handle: this.ensureHandle(),
itemId,
limit: limit ?? null,
});
} }
// ===== Playlist Methods (via Rust) ===== // ===== Playlist Methods (via Rust) =====
async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> { async createPlaylist(name: string, itemIds?: string[]): Promise<PlaylistCreatedResult> {
return invoke<PlaylistCreatedResult>("playlist_create", { return commands.playlistCreate(this.ensureHandle(), name, itemIds ?? null);
handle: this.ensureHandle(),
name,
itemIds: itemIds ?? null,
});
} }
async deletePlaylist(playlistId: string): Promise<void> { async deletePlaylist(playlistId: string): Promise<void> {
return invoke("playlist_delete", { await commands.playlistDelete(this.ensureHandle(), playlistId);
handle: this.ensureHandle(),
playlistId,
});
} }
async renamePlaylist(playlistId: string, name: string): Promise<void> { async renamePlaylist(playlistId: string, name: string): Promise<void> {
return invoke("playlist_rename", { await commands.playlistRename(this.ensureHandle(), playlistId, name);
handle: this.ensureHandle(),
playlistId,
name,
});
} }
async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> { async getPlaylistItems(playlistId: string): Promise<PlaylistEntry[]> {
return invoke<PlaylistEntry[]>("playlist_get_items", { return commands.playlistGetItems(this.ensureHandle(), playlistId);
handle: this.ensureHandle(),
playlistId,
});
} }
async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> { async addToPlaylist(playlistId: string, itemIds: string[]): Promise<void> {
return invoke("playlist_add_items", { await commands.playlistAddItems(this.ensureHandle(), playlistId, itemIds);
handle: this.ensureHandle(),
playlistId,
itemIds,
});
} }
async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> { async removeFromPlaylist(playlistId: string, entryIds: string[]): Promise<void> {
return invoke("playlist_remove_items", { await commands.playlistRemoveItems(this.ensureHandle(), playlistId, entryIds);
handle: this.ensureHandle(),
playlistId,
entryIds,
});
} }
async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> { async movePlaylistItem(playlistId: string, itemId: string, newIndex: number): Promise<void> {
return invoke("playlist_move_item", { await commands.playlistMoveItem(this.ensureHandle(), playlistId, itemId, newIndex);
handle: this.ensureHandle(),
playlistId,
itemId,
newIndex,
});
} }
// ===== Getters ===== // ===== Getters =====
+2 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { get } from "svelte/store"; import { get } from "svelte/store";
@@ -47,15 +47,12 @@
const repositoryHandle = repository.getHandle(); const repositoryHandle = repository.getHandle();
// Call Rust to get image as base64 data URL // Call Rust to get image as base64 data URL
const dataUrl = await invoke<string>("image_get_url", { const dataUrl = await commands.imageGetUrl(repositoryHandle, {
repositoryHandle,
request: {
itemId, itemId,
imageType, imageType,
maxWidth, maxWidth,
maxHeight, maxHeight,
tag, tag,
},
}); });
// Use data URL directly // Use data URL directly
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -34,7 +34,7 @@
loading = true; loading = true;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
stats = await invoke<StorageStats>("get_download_storage_stats", { userId }); stats = await commands.getDownloadStorageStats(userId);
} }
} catch (error) { } catch (error) {
console.error("Failed to load storage stats:", error); console.error("Failed to load storage stats:", error);
@@ -56,7 +56,7 @@
deleting = true; deleting = true;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
await invoke("delete_all_downloads", { userId }); await commands.deleteAllDownloads(userId);
await downloads.refresh(userId); await downloads.refresh(userId);
await loadStats(); await loadStats();
} }
@@ -73,7 +73,7 @@
deletingAlbum = albumId; deletingAlbum = albumId;
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
await invoke("delete_album_downloads", { albumId, userId }); await commands.deleteAlbumDownloads(albumId, userId);
await downloads.refresh(userId); await downloads.refresh(userId);
await loadStats(); await loadStats();
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
interface Props { interface Props {
@@ -89,18 +89,14 @@
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath); const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
// Get target directory for downloads // Get target directory for downloads
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
// Start each queued track download // Start each queued track download
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) { for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
try { try {
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id); const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
if (streamUrl) { if (streamUrl) {
await invoke("start_download", { await commands.startDownload(downloadIds[i], streamUrl, targetDir);
downloadId: downloadIds[i],
streamUrl,
targetDir,
});
} }
} catch (e) { } catch (e) {
console.error(`Failed to start download for track ${tracks[i].id}:`, e); console.error(`Failed to start download for track ${tracks[i].id}:`, e);
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings"; import { commands } from "$lib/api/bindings";
import DownloadButtonCore from "./DownloadButtonCore.svelte"; import DownloadButtonCore from "./DownloadButtonCore.svelte";
import type { DownloadState } from "./DownloadButtonCore.svelte"; import type { DownloadState } from "./DownloadButtonCore.svelte";
@@ -81,7 +80,7 @@
} }
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
console.log(" Target directory:", targetDir); console.log(" Target directory:", targetDir);
// Queue and start download in single atomic operation // Queue and start download in single atomic operation
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { MediaItem, PlaylistEntry } from "$lib/api/types"; import type { MediaItem, PlaylistEntry } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { toast } from "$lib/stores/toast"; import { toast } from "$lib/stores/toast";
@@ -51,9 +51,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds, trackIds,
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@@ -62,7 +60,6 @@
playlistId: playlist.id, playlistId: playlist.id,
playlistName: playlist.name, playlistName: playlist.name,
}, },
},
}); });
} catch (e) { } catch (e) {
console.error("[PlaylistDetail] Failed to play all:", e); console.error("[PlaylistDetail] Failed to play all:", e);
@@ -76,9 +73,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
const trackIds = entries.map(e => e.id); const trackIds = entries.map(e => e.id);
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds, trackIds,
startIndex: 0, startIndex: 0,
shuffle: true, shuffle: true,
@@ -87,7 +82,6 @@
playlistId: playlist.id, playlistId: playlist.id,
playlistName: playlist.name, playlistName: playlist.name,
}, },
},
}); });
} catch (e) { } catch (e) {
console.error("[PlaylistDetail] Failed to shuffle play:", e); console.error("[PlaylistDetail] Failed to shuffle play:", e);
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads, videoDownloads } from "$lib/stores/downloads"; import { downloads, videoDownloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -53,7 +53,7 @@
console.log("📺 Starting season download for:", seasonName, "quality:", quality); console.log("📺 Starting season download for:", seasonName, "quality:", quality);
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
const basePath = `${targetDir}/videos`; const basePath = `${targetDir}/videos`;
// Queue all episodes in this season // Queue all episodes in this season
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads, videoDownloads } from "$lib/stores/downloads"; import { downloads, videoDownloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -47,7 +47,7 @@
console.log("📺 Starting series download for:", seriesName, "quality:", quality); console.log("📺 Starting series download for:", seriesName, "quality:", quality);
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
const basePath = `${targetDir}/videos`; const basePath = `${targetDir}/videos`;
// Queue all episodes // Queue all episodes
+6 -11
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { PlayTracksContext } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { queue } from "$lib/stores/queue"; import { queue } from "$lib/stores/queue";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -62,14 +63,11 @@
if (context && context.type === "album") { if (context && context.type === "album") {
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`); console.log(`[TrackList] Playing track: "${track.name}" (ID: ${track.id}, index in list: ${index})`);
await invoke("player_play_album_track", { await commands.playerPlayAlbumTrack(repositoryHandle, {
repositoryHandle,
request: {
albumId: context.albumId, albumId: context.albumId,
albumName: context.albumName, albumName: context.albumName,
trackId: track.id, trackId: track.id,
shuffle: false, shuffle: false,
},
}); });
return; return;
} }
@@ -80,7 +78,7 @@
const trackIds = tracks.map((t) => t.id); const trackIds = tracks.map((t) => t.id);
// Determine context for queue // Determine context for queue
let playContext; let playContext: PlayTracksContext;
if (context?.type === "playlist") { if (context?.type === "playlist") {
playContext = { playContext = {
type: "playlist", type: "playlist",
@@ -88,17 +86,14 @@
playlistName: context.playlistName, playlistName: context.playlistName,
}; };
} else { } else {
playContext = { type: "custom" }; playContext = { type: "custom", label: null };
} }
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds, trackIds,
startIndex: index, startIndex: index,
shuffle: false, shuffle: false,
context: playContext, context: playContext,
},
}); });
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { downloads } from "$lib/stores/downloads"; import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets"; import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
interface Props { interface Props {
@@ -66,11 +66,11 @@
console.log("🎬 Starting video download for item:", itemId, "quality:", quality); console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
// Get stream URL based on quality // Get stream URL based on quality
const streamUrl = repo.getVideoDownloadUrl(itemId, quality); const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
console.log(" Stream URL obtained"); console.log(" Stream URL obtained");
// Get target directory // Get target directory
const targetDir = await invoke<string>("storage_get_path"); const targetDir = await commands.storageGetPath();
// Create file path // Create file path
const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_"); const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_");
@@ -107,11 +107,7 @@
await downloads.pinItem(itemId); await downloads.pinItem(itemId);
// Actually start the download // Actually start the download
await invoke("start_download", { await commands.startDownload(downloadId, streamUrl, targetDir);
downloadId,
streamUrl,
targetDir,
});
console.log(" Download started"); console.log(" Download started");
} catch (error) { } catch (error) {
console.error("Failed to start video download:", error); console.error("Failed to start video download:", error);
+8 -8
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 --> <!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -74,28 +74,28 @@
async function handleSeekEnd() { async function handleSeekEnd() {
seeking = false; seeking = false;
seekPending = true; // Keep showing target position until backend catches up seekPending = true; // Keep showing target position until backend catches up
await invoke("player_seek", { position: seekValue }); await commands.playerSeek(seekValue);
} }
// Control handlers for Controls component // Control handlers for Controls component
async function handlePlayPause() { async function handlePlayPause() {
await invoke("player_toggle"); await commands.playerToggle();
} }
async function handlePrevious() { async function handlePrevious() {
await invoke("player_previous"); await commands.playerPrevious();
} }
async function handleNext() { async function handleNext() {
await invoke("player_next"); await commands.playerNext();
} }
async function handleToggleShuffle() { async function handleToggleShuffle() {
await invoke("player_toggle_shuffle"); await commands.playerToggleShuffle();
} }
async function handleCycleRepeat() { async function handleCycleRepeat() {
await invoke("player_cycle_repeat"); await commands.playerCycleRepeat();
} }
// Prefer album ID for artwork (all tracks in an album share the same cover) // Prefer album ID for artwork (all tracks in an album share the same cover)
@@ -128,7 +128,7 @@
async function handleQueueItemClick(index: number) { async function handleQueueItemClick(index: number) {
try { try {
queue.skipTo(index); queue.skipTo(index);
await invoke("player_skip_to", { index }); await commands.playerSkipTo(index);
} catch (e) { } catch (e) {
console.error("Failed to skip to queue item:", e); console.error("Failed to skip to queue item:", e);
} }
+7 -7
View File
@@ -14,7 +14,7 @@
* @req: UR-010 - Control playback of Jellyfin remote sessions * @req: UR-010 - Control playback of Jellyfin remote sessions
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -105,23 +105,23 @@
// Control handlers for Controls component // Control handlers for Controls component
async function handlePlayPause() { async function handlePlayPause() {
await invoke("player_toggle"); await commands.playerToggle();
} }
async function handlePrevious() { async function handlePrevious() {
await invoke("player_previous"); await commands.playerPrevious();
} }
async function handleNext() { async function handleNext() {
await invoke("player_next"); await commands.playerNext();
} }
async function handleToggleShuffle() { async function handleToggleShuffle() {
await invoke("player_toggle_shuffle"); await commands.playerToggleShuffle();
} }
async function handleCycleRepeat() { async function handleCycleRepeat() {
await invoke("player_cycle_repeat"); await commands.playerCycleRepeat();
} }
// Scrubbing (seek) handler // Scrubbing (seek) handler
@@ -133,7 +133,7 @@
const newPosition = percent * displayDuration; const newPosition = percent * displayDuration;
try { try {
await invoke("player_seek", { position: newPosition }); await commands.playerSeek(newPosition);
haptics.tap(); haptics.tap();
} catch (err) { } catch (err) {
console.error("Failed to seek:", err); console.error("Failed to seek:", err);
+3 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action"; import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -79,10 +79,7 @@
queue.moveInQueue(fromIndex, toIndex); queue.moveInQueue(fromIndex, toIndex);
// Sync with backend // Sync with backend
await invoke("player_move_in_queue", { await commands.playerMoveInQueue(fromIndex, toIndex);
fromIndex,
toIndex,
});
} catch (e) { } catch (e) {
console.error("Failed to move queue item:", e); console.error("Failed to move queue item:", e);
// The store already updated optimistically, refresh if needed // The store already updated optimistically, refresh if needed
@@ -109,7 +106,7 @@
e.stopPropagation(); e.stopPropagation();
try { try {
queue.removeFromQueue(index); queue.removeFromQueue(index);
await invoke("player_remove_from_queue", { index }); await commands.playerRemoveFromQueue(index);
} catch (err) { } catch (err) {
console.error("Failed to remove from queue:", err); console.error("Failed to remove from queue:", err);
} }
+47 -45
View File
@@ -1,7 +1,7 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 --> <!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026 | DR-010, DR-023, DR-024 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy, untrack } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js"; import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
@@ -20,9 +20,13 @@
needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
onClose: () => void; onClose: () => void;
onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
onReportProgress?: (positionSeconds: number, isPaused: boolean) => void; // Reporting callbacks pass the played media's id explicitly so a late
onReportStart?: (positionSeconds: number) => void; // reportStop (fired from onDestroy during autoplay navigation) is attributed
onReportStop?: (positionSeconds: number) => void; // to the episode this player actually played, not the next episode whose URL
// is already active on the page.
onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
onReportStart?: (positionSeconds: number, reportId?: string) => void;
onReportStop?: (positionSeconds: number, reportId?: string) => void;
onEnded?: () => void; // Called when video playback ends naturally onEnded?: () => void; // Called when video playback ends naturally
onNext?: () => void; // Called when user clicks next episode button onNext?: () => void; // Called when user clicks next episode button
hasNext?: boolean; // Whether there is a next episode available hasNext?: boolean; // Whether there is a next episode available
@@ -30,6 +34,12 @@
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props(); let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
// The id this player instance reports progress against. Snapshotted from the
// media prop so a late reportStop (e.g. from onDestroy during autoplay
// navigation) is always attributed to the episode this player played.
// untrack() makes the intent explicit: capture the initial value only.
const reportMediaId = untrack(() => media?.id);
let videoElement: HTMLVideoElement | null = $state(null); let videoElement: HTMLVideoElement | null = $state(null);
let isPlaying = $state(false); let isPlaying = $state(false);
let currentTime = $state(0); let currentTime = $state(0);
@@ -140,13 +150,7 @@
const userId = auth.getUserId(); const userId = auth.getUserId();
if (!userId) return; if (!userId) return;
const preference = await invoke<{ seriesId: string, audioTrackDisplayTitle?: string | null, audioTrackLanguage?: string | null, audioTrackIndex?: number | null } | null>( const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);
"storage_get_series_audio_preference",
{
userId,
seriesId: media.seriesId
}
);
if (preference) { if (preference) {
console.log("[VideoPlayer] Loaded series audio preference:", preference); console.log("[VideoPlayer] Loaded series audio preference:", preference);
@@ -403,14 +407,12 @@
// Call Rust backend to start playback // Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5 // Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
// Send minimal video data - no complex serialization to avoid Tauri Android issues // Send minimal video data - no complex serialization to avoid Tauri Android issues
const response: any = await invoke("player_play_item", { const response: any = await commands.playerPlayItem({
item: {
streamUrl: currentStreamUrl, streamUrl: currentStreamUrl,
title: media.name, title: media.name,
id: media.id, id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264", videoCodec: needsTranscoding ? "hevc" : "h264",
needsTranscoding: needsTranscoding, needsTranscoding: needsTranscoding,
},
}); });
// Rust tells us which backend it's using // Rust tells us which backend it's using
@@ -422,7 +424,7 @@
if (useHtml5Element && !needsTranscoding) { if (useHtml5Element && !needsTranscoding) {
try { try {
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio"); console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
await invoke("player_stop"); await commands.playerStop();
didStopBackendEarly = true; // Track that we stopped the backend didStopBackendEarly = true; // Track that we stopped the backend
} catch (err) { } catch (err) {
console.warn("[VideoPlayer] Failed to stop backend player:", err); console.warn("[VideoPlayer] Failed to stop backend player:", err);
@@ -460,7 +462,7 @@
// For non-transcoded content, try to stop any backend player that might have started // For non-transcoded content, try to stop any backend player that might have started
if (!needsTranscoding) { if (!needsTranscoding) {
try { try {
await invoke("player_stop"); await commands.playerStop();
didStopBackendEarly = true; didStopBackendEarly = true;
} catch (stopErr) { } catch (stopErr) {
// Ignore errors when stopping // Ignore errors when stopping
@@ -478,7 +480,7 @@
// Report progress every 10 seconds while playing // Report progress every 10 seconds while playing
progressInterval = setInterval(() => { progressInterval = setInterval(() => {
if (isPlaying && !isSeeking && onReportProgress) { if (isPlaying && !isSeeking && onReportProgress) {
onReportProgress(currentTime, false); onReportProgress(currentTime, false, reportMediaId);
} }
}, 10000); }, 10000);
@@ -536,7 +538,7 @@
if (didStartNativePlayback && !didStopBackendEarly) { if (didStartNativePlayback && !didStopBackendEarly) {
try { try {
console.log("[VideoPlayer] Stopping backend player on component unmount"); console.log("[VideoPlayer] Stopping backend player on component unmount");
await invoke("player_stop"); await commands.playerStop();
} catch (err) { } catch (err) {
console.error("[VideoPlayer] Failed to stop backend player:", err); console.error("[VideoPlayer] Failed to stop backend player:", err);
} }
@@ -544,7 +546,7 @@
// Report stop when component is destroyed // Report stop when component is destroyed
if (onReportStop && currentTime > 0) { if (onReportStop && currentTime > 0) {
onReportStop(currentTime); onReportStop(currentTime, reportMediaId);
} }
}); });
@@ -738,7 +740,7 @@
startTimeUpdates(); // Start RAF loop for smooth time updates startTimeUpdates(); // Start RAF loop for smooth time updates
// Report playback start on first play // Report playback start on first play
if (!hasReportedStart && onReportStart) { if (!hasReportedStart && onReportStart) {
onReportStart(currentTime); onReportStart(currentTime, reportMediaId);
hasReportedStart = true; hasReportedStart = true;
} }
} }
@@ -748,7 +750,7 @@
stopTimeUpdates(); // Stop RAF loop when paused stopTimeUpdates(); // Stop RAF loop when paused
// Report progress when paused // Report progress when paused
if (onReportProgress) { if (onReportProgress) {
onReportProgress(currentTime, true); onReportProgress(currentTime, true, reportMediaId);
} }
} }
@@ -757,7 +759,7 @@
stopTimeUpdates(); // Stop RAF loop when ended stopTimeUpdates(); // Stop RAF loop when ended
// Report stop when video ends // Report stop when video ends
if (onReportStop) { if (onReportStop) {
onReportStop(currentTime); onReportStop(currentTime, reportMediaId);
} }
// Notify parent that video has ended (for next episode popup) // Notify parent that video has ended (for next episode popup)
if (onEnded) { if (onEnded) {
@@ -768,7 +770,7 @@
async function togglePlayPause() { async function togglePlayPause() {
if (!useHtml5Element) { if (!useHtml5Element) {
try { try {
const response = await invoke<{ state: string }>("player_toggle"); const response = (await commands.playerToggle()) as any;
isPlaying = response.state === "playing"; isPlaying = response.state === "playing";
} catch (err) { } catch (err) {
console.error("[VideoPlayer] Failed to toggle native player:", err); console.error("[VideoPlayer] Failed to toggle native player:", err);
@@ -807,13 +809,13 @@
} }
// Backend smart seeking handles both native and HTML5 // Backend smart seeking handles both native and HTML5
const response = await invoke<{strategy: string, position?: number, newUrl?: string, seekOffset?: number}>("player_seek_video", { const response = (await commands.playerSeekVideo(
repositoryHandle: repo.getHandle(), repo.getHandle(),
position: targetTime, targetTime,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null,
audioStreamIndex: selectedAudioTrackIndex ?? null, selectedAudioTrackIndex ?? null,
useHtml5: useHtml5Element, useHtml5Element
}); )) as any;
console.log("[VideoPlayer] Backend seek response:", response); console.log("[VideoPlayer] Backend seek response:", response);
@@ -1095,14 +1097,14 @@
if (!repo) throw new Error("Not authenticated"); if (!repo) throw new Error("Not authenticated");
// Call unified backend command // Call unified backend command
const response = await invoke<{strategy: string, success?: boolean, newUrl?: string, position?: number}>("player_switch_audio_track", { const response = (await commands.playerSwitchAudioTrack(
repositoryHandle: repo.getHandle(), repo.getHandle(),
streamIndex, streamIndex,
arrayIndex, arrayIndex,
useHtml5: useHtml5Element, useHtml5Element,
currentPosition: useHtml5Element && videoElement ? videoElement.currentTime + seekOffset : null, useHtml5Element && videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId: mediaSourceId ?? null, mediaSourceId ?? null
}); )) as any;
// Handle response based on strategy // Handle response based on strategy
if (response.strategy === "reloadStream" && useHtml5Element && videoElement) { if (response.strategy === "reloadStream" && useHtml5Element && videoElement) {
@@ -1171,14 +1173,14 @@
// Find the selected track info // Find the selected track info
const selectedTrack = audioTracks().find(t => t.index === streamIndex); const selectedTrack = audioTracks().find(t => t.index === streamIndex);
if (selectedTrack) { if (selectedTrack) {
await invoke("storage_save_series_audio_preference", { await commands.storageSaveSeriesAudioPreference(
userId, userId,
seriesId: media.seriesId, media.seriesId,
serverId: media.serverId, media.serverId ?? "",
audioTrackDisplayTitle: selectedTrack.displayTitle || null, selectedTrack.displayTitle || null,
audioTrackLanguage: selectedTrack.language || null, selectedTrack.language || null,
audioTrackIndex: streamIndex, streamIndex
}); );
console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language); console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
} }
} catch (err) { } catch (err) {
@@ -1229,7 +1231,7 @@
// Use array index for ExoPlayer (0-based position in subtitle tracks array) // Use array index for ExoPlayer (0-based position in subtitle tracks array)
// If streamIndex is null (disable), pass null; otherwise use arrayIndex // If streamIndex is null (disable), pass null; otherwise use arrayIndex
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex); const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
await invoke("player_set_subtitle_track", { streamIndex: indexToUse }); await commands.playerSetSubtitleTrack(indexToUse);
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse); console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
} catch (error) { } catch (error) {
console.error("[VideoPlayer] Failed to set subtitle track:", error); console.error("[VideoPlayer] Failed to set subtitle track:", error);
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { volume, isMuted, mergedVolume } from "$lib/stores/player"; import { volume, isMuted, mergedVolume } from "$lib/stores/player";
import { isRemoteMode } from "$lib/stores/playbackMode"; import { isRemoteMode } from "$lib/stores/playbackMode";
import { selectedSession, sessions } from "$lib/stores/sessions"; import { selectedSession, sessions } from "$lib/stores/sessions";
@@ -31,7 +31,7 @@
// Remote mode: send volume as 0-100 integer to remote session // Remote mode: send volume as 0-100 integer to remote session
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100)); await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
} else { } else {
await invoke("player_set_volume", { volume: newVolume }); await commands.playerSetVolume(newVolume);
} }
} }
@@ -39,7 +39,7 @@
if ($isRemoteMode && $selectedSession) { if ($isRemoteMode && $selectedSession) {
await sessions.sendToggleMute($selectedSession.id); await sessions.sendToggleMute($selectedSession.id);
} else { } else {
await invoke("player_toggle_mute"); await commands.playerToggleMute();
} }
} }
@@ -1,7 +1,7 @@
<!-- TRACES: UR-010 | DR-037 --> <!-- TRACES: UR-010 | DR-037 -->
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { sessions, controllableSessions, selectedSession } from "$lib/stores"; import { sessions, controllableSessions, selectedSession } from "$lib/stores";
import SessionPickerModal from "./SessionPickerModal.svelte"; import SessionPickerModal from "./SessionPickerModal.svelte";
@@ -42,18 +42,18 @@
// Set initial hint based on connection state // Set initial hint based on connection state
const hint = isConnected ? "cast_active" : "cast_discovery"; const hint = isConnected ? "cast_active" : "cast_discovery";
await invoke("sessions_set_polling_hint", { hint }); await commands.sessionsSetPollingHint(hint);
}); });
onDestroy(async () => { onDestroy(async () => {
// Reset to normal polling when component unmounts // Reset to normal polling when component unmounts
await invoke("sessions_set_polling_hint", { hint: "normal" }); await commands.sessionsSetPollingHint("normal");
}); });
// Update polling hint when connection state changes // Update polling hint when connection state changes
$effect(() => { $effect(() => {
const hint = isConnected ? "cast_active" : "cast_discovery"; const hint = isConnected ? "cast_active" : "cast_discovery";
invoke("sessions_set_polling_hint", { hint }); commands.sessionsSetPollingHint(hint);
}); });
const isConnected = $derived($selectedSession !== null); const isConnected = $derived($selectedSession !== null);
+2 -2
View File
@@ -8,7 +8,7 @@
* TRACES: UR-009 | DR-011 * TRACES: UR-009 | DR-011
*/ */
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
let cachedDeviceId: string | null = null; let cachedDeviceId: string | null = null;
@@ -30,7 +30,7 @@ export async function getDeviceId(): Promise<string> {
try { try {
// Rust backend handles generation and storage atomically // Rust backend handles generation and storage atomically
const deviceId = await invoke<string>("device_get_id"); const deviceId = await commands.deviceGetId();
cachedDeviceId = deviceId; cachedDeviceId = deviceId;
return deviceId; return deviceId;
} catch (e) { } catch (e) {
+3 -7
View File
@@ -1,7 +1,7 @@
// Favorites service - Handles toggling favorite status with optimistic updates // Favorites service - Handles toggling favorite status with optimistic updates
// TRACES: UR-017 | DR-021 // TRACES: UR-017 | DR-021
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
/** /**
@@ -29,11 +29,7 @@ export async function toggleFavorite(
const newIsFavorite = !currentIsFavorite; const newIsFavorite = !currentIsFavorite;
// 1. Update local database first (optimistic update) // 1. Update local database first (optimistic update)
await invoke("storage_toggle_favorite", { await commands.storageToggleFavorite(userId, itemId, newIsFavorite);
userId,
itemId,
isFavorite: newIsFavorite,
});
// 2. Sync to Jellyfin server // 2. Sync to Jellyfin server
try { try {
@@ -45,7 +41,7 @@ export async function toggleFavorite(
} }
// 3. Mark as synced // 3. Mark as synced
await invoke("storage_mark_synced", { userId, itemId }); await commands.storageMarkSynced(userId, itemId);
} catch (error) { } catch (error) {
console.error("Failed to sync favorite to server:", error); console.error("Failed to sync favorite to server:", error);
// Favorite is stored locally and will be synced later // Favorite is stored locally and will be synced later
+7 -16
View File
@@ -1,8 +1,8 @@
// Image cache service - Handles lazy caching of thumbnails with LRU eviction // Image cache service - Handles lazy caching of thumbnails with LRU eviction
// TRACES: UR-007 | DR-016 // TRACES: UR-007 | DR-016
import { invoke } from "@tauri-apps/api/core";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
/** /**
* Statistics about the thumbnail cache * Statistics about the thumbnail cache
@@ -38,11 +38,7 @@ export async function getCachedImageUrl(
// Try to get cached version // Try to get cached version
try { try {
const cachedPath = await invoke<string | null>("thumbnail_get_cached", { const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag);
itemId,
imageType,
tag,
});
if (cachedPath) { if (cachedPath) {
// Convert file path to asset URL for Tauri // Convert file path to asset URL for Tauri
@@ -62,12 +58,7 @@ export async function getCachedImageUrl(
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`; const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
// Trigger background caching (fire and forget) // Trigger background caching (fire and forget)
invoke("thumbnail_save", { commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
itemId,
imageType,
tag,
url: serverImageUrl,
}).catch((e) => {
// Silently fail - caching is best-effort // Silently fail - caching is best-effort
console.debug("Background thumbnail cache failed:", e); console.debug("Background thumbnail cache failed:", e);
}); });
@@ -80,7 +71,7 @@ export async function getCachedImageUrl(
* Get thumbnail cache statistics * Get thumbnail cache statistics
*/ */
export async function getCacheStats(): Promise<ImageCacheStats> { export async function getCacheStats(): Promise<ImageCacheStats> {
return invoke("thumbnail_get_stats"); return commands.thumbnailGetStats();
} }
/** /**
@@ -89,14 +80,14 @@ export async function getCacheStats(): Promise<ImageCacheStats> {
* @param limitBytes - The maximum cache size in bytes (0 = unlimited) * @param limitBytes - The maximum cache size in bytes (0 = unlimited)
*/ */
export async function setCacheLimit(limitBytes: number): Promise<void> { export async function setCacheLimit(limitBytes: number): Promise<void> {
return invoke("thumbnail_set_limit", { limitBytes }); await commands.thumbnailSetLimit(limitBytes);
} }
/** /**
* Clear all cached thumbnails * Clear all cached thumbnails
*/ */
export async function clearCache(): Promise<void> { export async function clearCache(): Promise<void> {
return invoke("thumbnail_clear_cache"); await commands.thumbnailClearCache();
} }
/** /**
@@ -105,7 +96,7 @@ export async function clearCache(): Promise<void> {
* @param itemId - The Jellyfin item ID * @param itemId - The Jellyfin item ID
*/ */
export async function deleteItemCache(itemId: string): Promise<void> { export async function deleteItemCache(itemId: string): Promise<void> {
return invoke("thumbnail_delete_item", { itemId }); await commands.thumbnailDeleteItem(itemId);
} }
/** /**
+5 -1
View File
@@ -39,6 +39,10 @@ export async function cancelAutoPlay() {
/** /**
* Navigate to the next episode via goto(). * Navigate to the next episode via goto().
* Uses replaceState to prevent history buildup when auto-advancing. * Uses replaceState to prevent history buildup when auto-advancing.
*
* Advancing to a next episode always starts that episode from the
* beginning, even if it was previously started or watched. The `restart`
* query param signals the player page to skip the resume-progress check.
*/ */
function navigateToEpisode(episode: MediaItem) { function navigateToEpisode(episode: MediaItem) {
if (isNavigating) { if (isNavigating) {
@@ -48,7 +52,7 @@ function navigateToEpisode(episode: MediaItem) {
isNavigating = true; isNavigating = true;
console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name); console.log("[NextEpisode] Navigating to next episode:", episode.id, episode.name);
nextEpisode.hidePopup(); nextEpisode.hidePopup();
goto(`/player/${episode.id}`, { replaceState: true }).finally(() => { goto(`/player/${episode.id}?restart=true`, { replaceState: true }).finally(() => {
isNavigating = false; isNavigating = false;
}); });
} }
+5 -19
View File
@@ -9,7 +9,7 @@
// //
// TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047 // TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
/** /**
@@ -42,13 +42,7 @@ export async function reportPlaybackStart(
// Update local DB with context (always works, even offline) // Update local DB with context (always works, even offline)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_context", { await commands.storageUpdatePlaybackContext(userId, itemId, positionTicks, contextType, contextId);
userId,
itemId,
positionTicks,
contextType,
contextId,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update playback context:", e); console.error("[PlaybackReporting] Failed to update playback context:", e);
} }
@@ -79,11 +73,7 @@ export async function reportPlaybackProgress(
// Update local DB only (progress updates are frequent, don't report to server) // Update local DB only (progress updates are frequent, don't report to server)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
userId,
itemId,
positionTicks,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -107,11 +97,7 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
// Update local DB first (always works, even offline) // Update local DB first (always works, even offline)
if (userId) { if (userId) {
try { try {
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(userId, itemId, positionTicks);
userId,
itemId,
positionTicks,
});
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to update local progress:", e); console.error("[PlaybackReporting] Failed to update local progress:", e);
} }
@@ -143,7 +129,7 @@ export async function markAsPlayed(itemId: string): Promise<void> {
// Update local DB first // Update local DB first
if (userId) { if (userId) {
try { try {
await invoke("storage_mark_played", { userId, itemId }); await commands.storageMarkPlayed(userId, itemId);
} catch (e) { } catch (e) {
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e); console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
} }
+4 -9
View File
@@ -9,7 +9,7 @@
*/ */
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { player, playbackPosition } from "$lib/stores/player"; import { player, playbackPosition } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue"; import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode"; import { playbackMode } from "$lib/stores/playbackMode";
@@ -230,12 +230,7 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
*/ */
async function updateQueueStatus(): Promise<void> { async function updateQueueStatus(): Promise<void> {
try { try {
const queueStatus = await invoke<{ const queueStatus = await commands.playerGetQueue();
hasNext: boolean;
hasPrevious: boolean;
shuffle: boolean;
repeat: string;
}>("player_get_queue");
// Import appState stores dynamically to avoid circular imports // Import appState stores dynamically to avoid circular imports
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState"); const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
@@ -266,7 +261,7 @@ function handleMediaLoaded(duration: number): void {
async function handlePlaybackEnded(): Promise<void> { async function handlePlaybackEnded(): Promise<void> {
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.) // Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
try { try {
await invoke("player_on_playback_ended"); await commands.playerOnPlaybackEnded(null, null);
} catch (e) { } catch (e) {
console.error("[playerEvents] Failed to handle playback ended:", e); console.error("[playerEvents] Failed to handle playback ended:", e);
// Fallback: set idle state on error // Fallback: set idle state on error
@@ -284,7 +279,7 @@ async function handleError(message: string, recoverable: boolean): Promise<void>
// Stop backend player to prevent orphaned playback // Stop backend player to prevent orphaned playback
// This also reports playback stopped to Jellyfin server // This also reports playback stopped to Jellyfin server
try { try {
await invoke("player_stop"); await commands.playerStop();
console.log("Backend player stopped after error"); console.log("Backend player stopped after error");
} catch (e) { } catch (e) {
console.error("Failed to stop player after error:", e); console.error("Failed to stop player after error:", e);
+19 -5
View File
@@ -6,6 +6,20 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { preloadUpcomingTracks, updateCacheConfig, getCacheConfig } from "./preload"; import { preloadUpcomingTracks, updateCacheConfig, getCacheConfig } from "./preload";
import type { CacheConfig } from "$lib/api/bindings";
// updateCacheConfig now takes a full CacheConfig (matches the backend command)
function makeConfig(overrides: Partial<CacheConfig> = {}): CacheConfig {
return {
queuePrecacheEnabled: true,
queuePrecacheCount: 5,
albumAffinityEnabled: false,
albumAffinityThreshold: 0.75,
storageLimit: 2 * 1024 * 1024 * 1024,
wifiOnly: false,
...overrides,
};
}
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: any) => { invoke: vi.fn(async (command: string, args?: any) => {
@@ -135,10 +149,10 @@ describe("preload service", () => {
describe("updateCacheConfig", () => { describe("updateCacheConfig", () => {
it("should update cache config", async () => { it("should update cache config", async () => {
const config = { const config = makeConfig({
queuePrecacheEnabled: false, queuePrecacheEnabled: false,
queuePrecacheCount: 10, queuePrecacheCount: 10,
}; });
await expect(updateCacheConfig(config)).resolves.toBeUndefined(); await expect(updateCacheConfig(config)).resolves.toBeUndefined();
}); });
@@ -147,7 +161,7 @@ describe("preload service", () => {
const { invoke } = await import("@tauri-apps/api/core"); const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke); const invokeSpy = vi.mocked(invoke);
const config = { queuePrecacheEnabled: true }; const config = makeConfig({ queuePrecacheEnabled: true });
await updateCacheConfig(config); await updateCacheConfig(config);
const call = invokeSpy.mock.calls.find( const call = invokeSpy.mock.calls.find(
@@ -157,8 +171,8 @@ describe("preload service", () => {
expect(call![1]).toHaveProperty("config", config); expect(call![1]).toHaveProperty("config", config);
}); });
it("should support partial config updates", async () => { it("should support overriding individual config options", async () => {
const config = { wifiOnly: true }; const config = makeConfig({ wifiOnly: true });
await expect(updateCacheConfig(config)).resolves.toBeUndefined(); await expect(updateCacheConfig(config)).resolves.toBeUndefined();
}); });
+8 -29
View File
@@ -5,15 +5,10 @@
* TRACES: UR-004, UR-011 | DR-006, DR-015 * TRACES: UR-004, UR-011 | DR-006, DR-015
*/ */
import { invoke } from '@tauri-apps/api/core'; import { commands } from '$lib/api/bindings';
import type { CacheConfig } from '$lib/api/bindings';
import { auth } from '$lib/stores/auth'; import { auth } from '$lib/stores/auth';
interface PreloadResult {
queuedCount: number;
alreadyDownloaded: number;
skipped: number;
}
interface PreloadOptions { interface PreloadOptions {
/** Enable debug logging */ /** Enable debug logging */
debug?: boolean; debug?: boolean;
@@ -39,10 +34,8 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
if (debug) console.log('[Preload] Triggering preload for user:', userId); if (debug) console.log('[Preload] Triggering preload for user:', userId);
const result = await invoke<PreloadResult>('player_preload_upcoming', { // downloadBasePath is currently unused in the backend
userId, const result = await commands.playerPreloadUpcoming(userId, '/downloads');
downloadBasePath: '/downloads' // This parameter is currently unused in the backend
});
if (debug) { if (debug) {
console.log('[Preload] Result:', { console.log('[Preload] Result:', {
@@ -66,27 +59,13 @@ export async function preloadUpcomingTracks(options: PreloadOptions = {}): Promi
/** /**
* Update smart cache configuration * Update smart cache configuration
*/ */
export async function updateCacheConfig(config: { export async function updateCacheConfig(config: CacheConfig): Promise<void> {
queuePrecacheEnabled?: boolean; await commands.playerSetCacheConfig(config);
queuePrecacheCount?: number;
albumAffinityEnabled?: boolean;
albumAffinityThreshold?: number;
storageLimit?: number;
wifiOnly?: boolean;
}): Promise<void> {
await invoke('player_set_cache_config', { config });
} }
/** /**
* Get current cache configuration * Get current cache configuration
*/ */
export async function getCacheConfig(): Promise<{ export async function getCacheConfig(): Promise<CacheConfig> {
queuePrecacheEnabled: boolean; return await commands.playerGetCacheConfig();
queuePrecacheCount: number;
albumAffinityEnabled: boolean;
albumAffinityThreshold: number;
storageLimit: number;
wifiOnly: boolean;
}> {
return await invoke('player_get_cache_config');
} }
+11 -25
View File
@@ -6,7 +6,7 @@
// //
// TRACES: UR-002, UR-017, UR-025 | DR-014 // TRACES: UR-002, UR-017, UR-025 | DR-014
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
// Types matching Rust structs // Types matching Rust structs
@@ -73,12 +73,12 @@ class SyncService {
throw new Error("Not authenticated"); throw new Error("Not authenticated");
} }
const id = await invoke<number>("sync_queue_mutation", { const id = await commands.syncQueueMutation(
userId, userId,
operation, operation,
itemId, itemId,
payload: payload ? JSON.stringify(payload) : null, payload ? JSON.stringify(payload) : null
}); );
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`); console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
return id; return id;
@@ -90,11 +90,7 @@ class SyncService {
*/ */
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> { async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_toggle_favorite", { await commands.storageToggleFavorite(auth.getUserId() ?? "", itemId, isFavorite);
userId: auth.getUserId(),
itemId,
isFavorite,
});
return this.queueMutation( return this.queueMutation(
isFavorite ? "mark_favorite" : "unmark_favorite", isFavorite ? "mark_favorite" : "unmark_favorite",
@@ -111,11 +107,7 @@ class SyncService {
positionTicks: number positionTicks: number
): Promise<number> { ): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_update_playback_progress", { await commands.storageUpdatePlaybackProgress(auth.getUserId() ?? "", itemId, positionTicks);
userId: auth.getUserId(),
itemId,
positionTicks,
});
return this.queueMutation("update_progress", itemId, { positionTicks }); return this.queueMutation("update_progress", itemId, { positionTicks });
} }
@@ -126,10 +118,7 @@ class SyncService {
*/ */
async queueMarkPlayed(itemId: string): Promise<number> { async queueMarkPlayed(itemId: string): Promise<number> {
// Update local state first // Update local state first
await invoke("storage_mark_played", { await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
userId: auth.getUserId(),
itemId,
});
return this.queueMutation("mark_played", itemId); return this.queueMutation("mark_played", itemId);
} }
@@ -143,7 +132,7 @@ class SyncService {
return 0; return 0;
} }
return invoke<number>("sync_get_pending_count", { userId }); return commands.syncGetPendingCount(userId);
} }
/** /**
@@ -155,17 +144,14 @@ class SyncService {
return []; return [];
} }
return invoke<SyncQueueItem[]>("sync_get_pending", { return commands.syncGetPending(userId, limit ?? null);
userId,
limit,
});
} }
/** /**
* Clean up completed operations older than specified days * Clean up completed operations older than specified days
*/ */
async cleanup(daysOld: number = 7): Promise<number> { async cleanup(daysOld: number = 7): Promise<number> {
const deleted = await invoke<number>("sync_cleanup_completed", { daysOld }); const deleted = await commands.syncCleanupCompleted(daysOld);
console.log(`[SyncService] Cleaned up ${deleted} old completed operations`); console.log(`[SyncService] Cleaned up ${deleted} old completed operations`);
return deleted; return deleted;
} }
@@ -204,7 +190,7 @@ class SyncService {
async clearUser(): Promise<void> { async clearUser(): Promise<void> {
const userId = auth.getUserId(); const userId = auth.getUserId();
if (userId) { if (userId) {
await invoke("sync_clear_user", { userId }); await commands.syncClearUser(userId);
console.log("[SyncService] Cleared sync queue for user"); console.log("[SyncService] Cleared sync queue for user");
} }
} }
+48 -92
View File
@@ -6,10 +6,11 @@
// TRACES: UR-009, UR-012 | IR-009, IR-014 // TRACES: UR-009, UR-012 | IR-009, IR-014
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import { RepositoryClient } from "$lib/api/repository-client"; import { RepositoryClient } from "$lib/api/repository-client";
import type { User, AuthResult } from "$lib/api/types"; import type { User, AuthResult } from "$lib/api/types";
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
import { connectivity } from "./connectivity"; import { connectivity } from "./connectivity";
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId"; import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
@@ -29,29 +30,6 @@ interface AuthState {
sessionVerified: boolean; sessionVerified: boolean;
} }
interface Session {
userId: string;
username: string;
serverId: string;
serverUrl: string;
serverName: string;
accessToken: string;
verified: boolean;
needsReauth: boolean;
}
interface ServerInfo {
name: string;
version: string;
id: string;
normalizedUrl: string;
}
interface SecurityStatus {
usingKeyring: boolean;
storageType: string;
}
function createAuthStore() { function createAuthStore() {
const initialState: AuthState = { const initialState: AuthState = {
isAuthenticated: false, isAuthenticated: false,
@@ -163,7 +141,7 @@ function createAuthStore() {
try { try {
// Check security status // Check security status
try { try {
const securityStatus = await invoke<SecurityStatus>("storage_get_security_status"); const securityStatus = await commands.storageGetSecurityStatus();
console.log("[Auth] Security status:", securityStatus); console.log("[Auth] Security status:", securityStatus);
if (!securityStatus.usingKeyring) { if (!securityStatus.usingKeyring) {
update((s) => ({ update((s) => ({
@@ -178,7 +156,7 @@ function createAuthStore() {
// Initialize auth manager and get session // Initialize auth manager and get session
console.log("[Auth] Initializing auth manager..."); console.log("[Auth] Initializing auth manager...");
const session = await invoke<Session | null>("auth_initialize"); const session = await commands.authInitialize();
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found"); console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
if (session) { if (session) {
@@ -192,12 +170,12 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
try { try {
console.log("[Auth] Configuring Rust player with restored session..."); console.log("[Auth] Configuring Rust player with restored session...");
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl: session.serverUrl, session.serverUrl,
accessToken: session.accessToken, session.accessToken,
userId: session.userId, session.userId,
deviceId: deviceId, deviceId
}); );
console.log("[Auth] Rust player configured for automatic playback reporting"); console.log("[Auth] Rust player configured for automatic playback reporting");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to configure Rust player:", error); console.error("[Auth] Failed to configure Rust player:", error);
@@ -231,7 +209,7 @@ function createAuthStore() {
// Start background session verification // Start background session verification
try { try {
const verifyDeviceId = await getDeviceId(); const verifyDeviceId = await getDeviceId();
await invoke("auth_start_verification", { deviceId: verifyDeviceId }); await commands.authStartVerification(verifyDeviceId);
console.log("[Auth] Background verification started"); console.log("[Auth] Background verification started");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to start verification:", error); console.error("[Auth] Failed to start verification:", error);
@@ -273,7 +251,7 @@ function createAuthStore() {
try { try {
console.log("[Auth] Connecting to server:", serverUrl); console.log("[Auth] Connecting to server:", serverUrl);
const serverInfo = await invoke<ServerInfo>("auth_connect_to_server", { serverUrl }); const serverInfo = await commands.authConnectToServer(serverUrl);
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version); console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl); console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
@@ -302,38 +280,24 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Logging in as:", username); console.log("[Auth] Logging in as:", username);
const authResult = await invoke<AuthResult>("auth_login", { const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
serverUrl,
username,
password,
deviceId,
});
console.log("[Auth] Login successful:", authResult.user); console.log("[Auth] Login successful:", authResult.user);
// Save to storage // Save to storage
await invoke("storage_save_server", { await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
id: authResult.serverId,
name: serverName,
url: serverUrl,
version: null,
});
await invoke("storage_save_user", { await commands.storageSaveUser(
id: authResult.user.id, authResult.user.id,
serverId: authResult.serverId, authResult.serverId,
username: authResult.user.name, authResult.user.name,
accessToken: authResult.accessToken, authResult.accessToken
}); );
await invoke("storage_set_active_user", { await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
userId: authResult.user.id,
serverId: authResult.serverId,
});
// Set session in auth manager with server name // Set session in auth manager with server name
await invoke("auth_set_session", { await commands.authSetSession({
session: {
userId: authResult.user.id, userId: authResult.user.id,
username: authResult.user.name, username: authResult.user.name,
serverId: authResult.serverId, serverId: authResult.serverId,
@@ -342,7 +306,6 @@ function createAuthStore() {
accessToken: authResult.accessToken, accessToken: authResult.accessToken,
verified: true, verified: true,
needsReauth: false, needsReauth: false,
},
}); });
// Create RepositoryClient // Create RepositoryClient
@@ -352,12 +315,12 @@ function createAuthStore() {
// Configure Rust player // Configure Rust player
try { try {
const playerDeviceId = await getDeviceId(); const playerDeviceId = await getDeviceId();
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl, serverUrl,
accessToken: authResult.accessToken, authResult.accessToken,
userId: authResult.user.id, authResult.user.id,
deviceId: playerDeviceId, playerDeviceId
}); );
console.log("[Auth] Rust player configured for playback reporting"); console.log("[Auth] Rust player configured for playback reporting");
} catch (error) { } catch (error) {
console.error("[Auth] Failed to configure Rust player:", error); console.error("[Auth] Failed to configure Rust player:", error);
@@ -380,7 +343,7 @@ function createAuthStore() {
// Start background verification // Start background verification
try { try {
const verifyDeviceId = await getDeviceId(); const verifyDeviceId = await getDeviceId();
await invoke("auth_start_verification", { deviceId: verifyDeviceId }); await commands.authStartVerification(verifyDeviceId);
} catch (error) { } catch (error) {
console.error("[Auth] Failed to start verification:", error); console.error("[Auth] Failed to start verification:", error);
} }
@@ -404,25 +367,22 @@ function createAuthStore() {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Re-authenticating..."); console.log("[Auth] Re-authenticating...");
const authResult = await invoke<AuthResult>("auth_reauthenticate", { const authResult = await commands.authReauthenticate(password, deviceId);
password,
deviceId,
});
console.log("[Auth] Re-authentication successful"); console.log("[Auth] Re-authentication successful");
// Update storage // Update storage
await invoke("storage_save_user", { await commands.storageSaveUser(
id: authResult.user.id, authResult.user.id,
serverId: authResult.serverId, authResult.serverId,
username: authResult.user.name, authResult.user.name,
accessToken: authResult.accessToken, authResult.accessToken
}); );
// Recreate repository with new credentials // Recreate repository with new credentials
if (repository) { if (repository) {
await repository.destroy(); await repository.destroy();
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
if (session) { if (session) {
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId); await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
} }
@@ -431,12 +391,12 @@ function createAuthStore() {
// Reconfigure player // Reconfigure player
try { try {
const playerDeviceId = await getDeviceId(); const playerDeviceId = await getDeviceId();
await invoke("player_configure_jellyfin", { await commands.playerConfigureJellyfin(
serverUrl: repository ? await getCurrentSessionServerUrl() : "", repository ? await getCurrentSessionServerUrl() : "",
accessToken: authResult.accessToken, authResult.accessToken,
userId: authResult.user.id, authResult.user.id,
deviceId: playerDeviceId, playerDeviceId
}); );
} catch (error) { } catch (error) {
console.error("[Auth] Failed to reconfigure player:", error); console.error("[Auth] Failed to reconfigure player:", error);
} }
@@ -467,19 +427,15 @@ function createAuthStore() {
*/ */
async function logout() { async function logout() {
try { try {
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
if (session) { if (session) {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
await invoke("auth_logout", { await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
serverUrl: session.serverUrl,
accessToken: session.accessToken,
deviceId,
});
} }
// Disable Jellyfin reporting in player // Disable Jellyfin reporting in player
try { try {
await invoke("player_disable_jellyfin"); await commands.playerDisableJellyfin();
} catch (error) { } catch (error) {
console.error("[Auth] Failed to disable player reporting:", error); console.error("[Auth] Failed to disable player reporting:", error);
} }
@@ -524,7 +480,7 @@ function createAuthStore() {
*/ */
async function getCurrentSession() { async function getCurrentSession() {
try { try {
return await invoke<Session | null>("auth_get_session"); return await commands.authGetSession();
} catch (error) { } catch (error) {
console.error("[Auth] Failed to get current session:", error); console.error("[Auth] Failed to get current session:", error);
return null; return null;
@@ -551,7 +507,7 @@ function createAuthStore() {
* Helper to get server URL from current session. * Helper to get server URL from current session.
*/ */
async function getCurrentSessionServerUrl(): Promise<string> { async function getCurrentSessionServerUrl(): Promise<string> {
const session = await invoke<Session | null>("auth_get_session"); const session = await commands.authGetSession();
return session?.serverUrl || ""; return session?.serverUrl || "";
} }
@@ -562,7 +518,7 @@ function createAuthStore() {
try { try {
const deviceId = await getDeviceId(); const deviceId = await getDeviceId();
console.log("[Auth] Retrying session verification after reconnection"); console.log("[Auth] Retrying session verification after reconnection");
await invoke("auth_start_verification", { deviceId }); await commands.authStartVerification(deviceId);
} catch (error) { } catch (error) {
console.error("[Auth] Failed to retry verification:", error); console.error("[Auth] Failed to retry verification:", error);
} }
+36 -67
View File
@@ -1,13 +1,18 @@
// Connectivity state store for offline support // Connectivity state store for offline support
// //
// Simplified wrapper over Rust connectivity monitor. // Pure reflection of the Rust ConnectivityMonitor. Reachability is the single
// The Rust backend handles all polling, reachability checks, and adaptive intervals. // source of truth in Rust, derived from real repository traffic (success/
// RepoError), with a time-window debounce before going offline and instant
// recovery. This store only listens for `connectivity:changed` events and
// mirrors status; it does not decide reachability itself. navigator.onLine is
// advisory and triggers a recheck rather than forcing offline.
// See docs/architecture/07-connectivity.md.
// TRACES: UR-002 | DR-013 // TRACES: UR-002 | DR-013
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
export interface ConnectivityState { export interface ConnectivityState {
/** Browser's navigator.onLine status */ /** Browser's navigator.onLine status */
@@ -29,13 +34,6 @@ export interface ConnectivityEvents {
onServerReconnected?: () => void; onServerReconnected?: () => void;
} }
interface RustConnectivityStatus {
isServerReachable: boolean;
lastChecked: string | null;
connectionError: string | null;
isChecking: boolean;
}
function createConnectivityStore() { function createConnectivityStore() {
const initialState: ConnectivityState = { const initialState: ConnectivityState = {
isOnline: browser ? navigator.onLine : true, isOnline: browser ? navigator.onLine : true,
@@ -68,22 +66,27 @@ function createConnectivityStore() {
} }
}); });
// Listen to browser online/offline events and update state // Listen to browser online/offline events. These are ADVISORY only — the
// Rust ConnectivityMonitor (fed by real repository traffic) is the source of
// truth for server reachability. navigator.onLine can be wrong (e.g. a
// LAN-only server is still reachable while the browser reports "offline"),
// so we use these events to trigger an immediate recheck rather than forcing
// the offline state. See docs/architecture/07-connectivity.md.
window.addEventListener("online", () => { window.addEventListener("online", () => {
update((s) => ({ ...s, isOnline: true })); update((s) => ({ ...s, isOnline: true }));
// Device regained network — ask the backend to re-verify the server now.
checkServerReachable().catch((err) => {
console.debug("[ConnectivityStore] Recheck after 'online' failed:", err);
});
}); });
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
update((s) => ({ // Reflect the browser's view of the device link, but let the backend
...s, // decide whether the server is actually reachable.
isOnline: false, update((s) => ({ ...s, isOnline: false }));
isServerReachable: false, checkServerReachable().catch((err) => {
connectionError: "Device is offline", console.debug("[ConnectivityStore] Recheck after 'offline' failed:", err);
})); });
if (eventHandlers.onConnectivityChange) {
eventHandlers.onConnectivityChange(false);
}
}); });
} }
@@ -115,10 +118,10 @@ function createConnectivityStore() {
*/ */
async function checkServerReachable(): Promise<boolean> { async function checkServerReachable(): Promise<boolean> {
try { try {
const isReachable = await invoke<boolean>("connectivity_check_server"); const isReachable = await commands.connectivityCheckServer();
// Fetch updated status from Rust // Fetch updated status from Rust
const status = await invoke<RustConnectivityStatus>("connectivity_get_status"); const status = await commands.connectivityGetStatus();
update((s) => ({ update((s) => ({
...s, ...s,
isServerReachable: status.isServerReachable, isServerReachable: status.isServerReachable,
@@ -145,13 +148,13 @@ function createConnectivityStore() {
console.log("[ConnectivityStore] Starting monitoring for:", url); console.log("[ConnectivityStore] Starting monitoring for:", url);
// Set the server URL // Set the server URL
await invoke("connectivity_set_server_url", { url }); await commands.connectivitySetServerUrl(url);
// Start the Rust monitoring task (performs immediate check) // Start the Rust monitoring task (performs immediate check)
await invoke("connectivity_start_monitoring"); await commands.connectivityStartMonitoring();
// Get the initial status immediately after starting // Get the initial status immediately after starting
const status = await invoke<RustConnectivityStatus>("connectivity_get_status"); const status = await commands.connectivityGetStatus();
update((s) => ({ update((s) => ({
...s, ...s,
isServerReachable: status.isServerReachable, isServerReachable: status.isServerReachable,
@@ -179,7 +182,7 @@ function createConnectivityStore() {
if (!isMonitoring) return; if (!isMonitoring) return;
try { try {
await invoke("connectivity_stop_monitoring"); await commands.connectivityStopMonitoring();
isMonitoring = false; isMonitoring = false;
eventHandlers = {}; eventHandlers = {};
console.log("[ConnectivityStore] Stopped monitoring"); console.log("[ConnectivityStore] Stopped monitoring");
@@ -193,7 +196,7 @@ function createConnectivityStore() {
*/ */
async function setServerUrl(url: string): Promise<void> { async function setServerUrl(url: string): Promise<void> {
try { try {
await invoke("connectivity_set_server_url", { url }); await commands.connectivitySetServerUrl(url);
} catch (error) { } catch (error) {
console.error("[ConnectivityStore] Failed to set server URL:", error); console.error("[ConnectivityStore] Failed to set server URL:", error);
} }
@@ -206,43 +209,11 @@ function createConnectivityStore() {
return checkServerReachable(); return checkServerReachable();
} }
/** // Reachability is now driven by the Rust backend from real repository
* Mark server as reachable (e.g., after successful API call) // traffic (see docs/architecture/07-connectivity.md). The frontend no longer
*/ // mutates reachability itself, so the former markReachable/markUnreachable
async function markReachable(): Promise<void> { // helpers have been removed. The underlying Rust commands remain available
try { // for deliberate signals if ever needed.
await invoke("connectivity_mark_reachable");
// Update local state
update((s) => ({
...s,
isServerReachable: true,
lastChecked: new Date(),
connectionError: null,
}));
} catch (error) {
console.error("[ConnectivityStore] Failed to mark reachable:", error);
}
}
/**
* Mark server as unreachable (e.g., after failed API call)
*/
async function markUnreachable(error?: string): Promise<void> {
try {
await invoke("connectivity_mark_unreachable", { error: error ?? null });
// Update local state
update((s) => ({
...s,
isServerReachable: false,
lastChecked: new Date(),
connectionError: error || "Server unreachable",
}));
} catch (err) {
console.error("[ConnectivityStore] Failed to mark unreachable:", err);
}
}
return { return {
subscribe, subscribe,
@@ -251,8 +222,6 @@ function createConnectivityStore() {
setServerUrl, setServerUrl,
forceCheck, forceCheck,
checkServerReachable, checkServerReachable,
markReachable,
markUnreachable,
isNetworkError, isNetworkError,
}; };
} }
+1 -1
View File
@@ -131,7 +131,7 @@ describe("downloads store", () => {
expect(mockInvoke).toHaveBeenCalledWith("get_downloads", { expect(mockInvoke).toHaveBeenCalledWith("get_downloads", {
userId: "user-1", userId: "user-1",
statusFilter: undefined, statusFilter: null,
}); });
const state = get(downloads); const state = get(downloads);
+26 -34
View File
@@ -1,7 +1,6 @@
// Download manager state store // Download manager state store
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 // TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
import { writable, derived, get } from 'svelte/store'; import { writable, derived, get } from 'svelte/store';
import { invoke } from '@tauri-apps/api/core';
import { commands } from '$lib/api/bindings'; import { commands } from '$lib/api/bindings';
import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { listen, type UnlistenFn } from '@tauri-apps/api/event';
@@ -95,13 +94,10 @@ function createDownloadsStore() {
try { try {
console.log('🔄 Refreshing downloads for user:', userId); console.log('🔄 Refreshing downloads for user:', userId);
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>( const response = (await commands.getDownloads(
'get_downloads',
{
userId, userId,
statusFilter statusFilter ?? null
} )) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
);
console.log(' Got', response.downloads.length, 'downloads from backend'); console.log(' Got', response.downloads.length, 'downloads from backend');
console.log(' Stats:', response.stats); console.log(' Stats:', response.stats);
@@ -182,11 +178,7 @@ function createDownloadsStore() {
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> { async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
try { try {
console.log('📥 downloadAlbum called:', { albumId, userId, basePath }); console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
const downloadIds = await invoke<number[]>('download_album', { const downloadIds = await commands.downloadAlbum(albumId, userId, basePath);
albumId,
userId,
basePath
});
console.log(' Got download IDs from backend:', downloadIds); console.log(' Got download IDs from backend:', downloadIds);
// Refresh downloads // Refresh downloads
@@ -267,13 +259,13 @@ function createDownloadsStore() {
basePath, basePath,
qualityPreset qualityPreset
}); });
const downloadIds = await invoke<number[]>('download_series', { const downloadIds = await commands.downloadSeries(
seriesId, seriesId,
seriesName, seriesName,
userId, userId,
basePath, basePath,
qualityPreset qualityPreset ?? null
}); );
console.log(' Queued', downloadIds.length, 'episodes for download'); console.log(' Queued', downloadIds.length, 'episodes for download');
// Refresh downloads // Refresh downloads
@@ -306,15 +298,15 @@ function createDownloadsStore() {
seasonNumber, seasonNumber,
qualityPreset qualityPreset
}); });
const downloadIds = await invoke<number[]>('download_season', { const downloadIds = await commands.downloadSeason(
seasonId, seasonId,
seriesName, seriesName,
seasonName, seasonName,
seasonNumber, seasonNumber,
userId, userId,
basePath, basePath,
qualityPreset qualityPreset ?? null
}); );
console.log(' Queued', downloadIds.length, 'episodes for download'); console.log(' Queued', downloadIds.length, 'episodes for download');
// Refresh downloads // Refresh downloads
@@ -332,7 +324,7 @@ function createDownloadsStore() {
*/ */
async pinItem(itemId: string): Promise<void> { async pinItem(itemId: string): Promise<void> {
try { try {
await invoke('pin_item', { itemId }); await commands.pinItem(itemId);
} catch (error) { } catch (error) {
console.error('Failed to pin item:', error); console.error('Failed to pin item:', error);
throw error; throw error;
@@ -344,7 +336,7 @@ function createDownloadsStore() {
*/ */
async unpinItem(itemId: string): Promise<void> { async unpinItem(itemId: string): Promise<void> {
try { try {
await invoke('unpin_item', { itemId }); await commands.unpinItem(itemId);
} catch (error) { } catch (error) {
console.error('Failed to unpin item:', error); console.error('Failed to unpin item:', error);
throw error; throw error;
@@ -356,7 +348,7 @@ function createDownloadsStore() {
*/ */
async isItemPinned(itemId: string): Promise<boolean> { async isItemPinned(itemId: string): Promise<boolean> {
try { try {
return await invoke<boolean>('is_item_pinned', { itemId }); return await commands.isItemPinned(itemId);
} catch (error) { } catch (error) {
console.error('Failed to check pin status:', error); console.error('Failed to check pin status:', error);
return false; return false;
@@ -368,7 +360,7 @@ function createDownloadsStore() {
*/ */
async pause(downloadId: number): Promise<void> { async pause(downloadId: number): Promise<void> {
try { try {
await invoke('pause_download', { downloadId }); await commands.pauseDownload(downloadId);
} catch (error) { } catch (error) {
console.error('Failed to pause download:', error); console.error('Failed to pause download:', error);
throw error; throw error;
@@ -380,7 +372,7 @@ function createDownloadsStore() {
*/ */
async resume(downloadId: number): Promise<void> { async resume(downloadId: number): Promise<void> {
try { try {
await invoke('resume_download', { downloadId }); await commands.resumeDownload(downloadId);
} catch (error) { } catch (error) {
console.error('Failed to resume download:', error); console.error('Failed to resume download:', error);
throw error; throw error;
@@ -392,7 +384,7 @@ function createDownloadsStore() {
*/ */
async cancel(downloadId: number): Promise<void> { async cancel(downloadId: number): Promise<void> {
try { try {
await invoke('cancel_download', { downloadId }); await commands.cancelDownload(downloadId);
} catch (error) { } catch (error) {
console.error('Failed to cancel download:', error); console.error('Failed to cancel download:', error);
throw error; throw error;
@@ -404,7 +396,7 @@ function createDownloadsStore() {
*/ */
async delete(downloadId: number): Promise<void> { async delete(downloadId: number): Promise<void> {
try { try {
await invoke('delete_download', { downloadId }); await commands.deleteDownload(downloadId);
update((state) => { update((state) => {
const { [downloadId]: removed, ...remaining } = state.downloads; const { [downloadId]: removed, ...remaining } = state.downloads;
return { ...state, downloads: remaining }; return { ...state, downloads: remaining };
@@ -574,11 +566,11 @@ function handleDownloadEvent(payload: DownloadEvent): void {
case 'completed': case 'completed':
if (download) { if (download) {
// Persist to database // Persist to database
invoke('mark_download_completed', { commands.markDownloadCompleted(
downloadId: payload.downloadId, payload.downloadId,
bytesDownloaded: payload.totalBytes || download.fileSize || download.bytesDownloaded, payload.totalBytes || download.fileSize || download.bytesDownloaded,
filePath: payload.filePath || download.filePath payload.filePath || download.filePath
}).catch((err) => console.error('Failed to persist download completion:', err)); ).catch((err) => console.error('Failed to persist download completion:', err));
updateDownloadInStore(payload.downloadId, { updateDownloadInStore(payload.downloadId, {
status: 'completed', status: 'completed',
@@ -592,10 +584,10 @@ function handleDownloadEvent(payload: DownloadEvent): void {
case 'failed': case 'failed':
if (download) { if (download) {
// Persist to database // Persist to database
invoke('mark_download_failed', { commands.markDownloadFailed(
downloadId: payload.downloadId, payload.downloadId,
errorMessage: payload.error || 'Unknown error' payload.error || 'Unknown error'
}).catch((err) => console.error('Failed to persist download failure:', err)); ).catch((err) => console.error('Failed to persist download failure:', err));
updateDownloadInStore(payload.downloadId, { updateDownloadInStore(payload.downloadId, {
status: 'failed', status: 'failed',
-236
View File
@@ -1,236 +0,0 @@
/**
* Integration tests for playbackMode store
*
* Tests that the store calls Tauri commands with correct parameter names.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* So Rust `repository_handle: String` frontend sends `repositoryHandle`.
* Nested struct fields with #[serde(rename_all = "camelCase")] also use camelCase.
*/
import { vi, describe, it, expect, beforeEach } from "vitest";
describe("playbackMode store - Tauri invoke parameter verification", () => {
let mockInvokedCalls: Array<{ command: string; args: Record<string, any> }> =
[];
beforeEach(() => {
mockInvokedCalls = [];
// Mock invoke to capture calls
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(async (command: string, args?: Record<string, any>) => {
mockInvokedCalls.push({ command, args: args || {} });
return { success: true };
}),
}));
});
describe("player_play_tracks command parameters", () => {
it("should use repositoryHandle (camelCase, auto-converted by Tauri v2)", () => {
const correctCall = {
repositoryHandle: "test-handle-123", // ✓ CORRECT - Tauri v2 auto-converts
request: {
trackIds: ["track-1"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "",
},
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
it("nested request fields use camelCase", () => {
const correctRequest = {
trackIds: ["track-1"], // ✓ camelCase for nested struct field
startIndex: 0, // ✓ camelCase
shuffle: false,
context: {
type: "search",
searchQuery: "", // ✓ camelCase for context field
},
};
expect(Object.keys(correctRequest)).toContain("trackIds");
expect(Object.keys(correctRequest)).toContain("startIndex");
expect(Object.keys(correctRequest.context)).toContain("searchQuery");
});
});
describe("playback_mode_transfer_to_local command parameters", () => {
it("should use currentItemId and positionTicks (camelCase)", () => {
const correctCall = {
currentItemId: "item-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("currentItemId");
expect(Object.keys(correctCall)).toContain("positionTicks");
expect(Object.keys(correctCall)).not.toContain("current_item_id");
expect(Object.keys(correctCall)).not.toContain("position_ticks");
});
});
describe("Session commands use sessionId (camelCase)", () => {
it("remote_send_command uses sessionId", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
command: "PlayPause",
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).not.toContain("session_id");
});
it("remote_play_on_session uses sessionId, itemIds, startIndex", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
itemIds: ["id1", "id2"], // ✓ CORRECT
startIndex: 0, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).toContain("itemIds");
expect(Object.keys(correctCall)).toContain("startIndex");
expect(Object.keys(correctCall)).not.toContain("session_id");
expect(Object.keys(correctCall)).not.toContain("item_ids");
expect(Object.keys(correctCall)).not.toContain("start_index");
});
it("remote_session_seek uses sessionId and positionTicks", () => {
const correctCall = {
sessionId: "session-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("sessionId");
expect(Object.keys(correctCall)).toContain("positionTicks");
expect(Object.keys(correctCall)).not.toContain("session_id");
expect(Object.keys(correctCall)).not.toContain("position_ticks");
});
});
describe("Download commands use itemId (camelCase)", () => {
it("pin_item uses itemId", () => {
const correctCall = {
itemId: "item-123", // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("itemId");
expect(Object.keys(correctCall)).not.toContain("item_id");
});
it("unpin_item uses itemId", () => {
const correctCall = {
itemId: "item-123", // ✓ CORRECT
};
expect(Object.keys(correctCall)).toContain("itemId");
expect(Object.keys(correctCall)).not.toContain("item_id");
});
});
describe("Queue commands use repositoryHandle (camelCase)", () => {
it("player_add_track_by_id uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
trackId: "track-123",
position: 0,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
it("player_add_tracks_by_ids uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
trackIds: ["track-1", "track-2"],
position: 0,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
});
});
describe("Player commands", () => {
it("player_play_album_track uses repositoryHandle", () => {
const correctCall = {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false,
},
};
expect(Object.keys(correctCall)).toContain("repositoryHandle");
expect(Object.keys(correctCall)).not.toContain("repository_handle");
// Nested struct fields use camelCase
expect(Object.keys(correctCall.request)).toContain("albumId");
expect(Object.keys(correctCall.request)).toContain("albumName");
expect(Object.keys(correctCall.request)).toContain("trackId");
});
it("player_seek uses position (simple types don't need renaming)", () => {
const correctCall = {
position: 500.5,
};
expect(correctCall.position).toBe(500.5);
});
});
describe("Error detection - what NOT to do", () => {
it("repository_handle (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
repository_handle: "handle-123", // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("repositoryHandle");
expect(Object.keys(wrongCall)).toContain("repository_handle");
});
it("session_id (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
session_id: "session-123", // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("sessionId");
expect(Object.keys(wrongCall)).toContain("session_id");
});
it("item_ids (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
item_ids: ["id1", "id2"], // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("itemIds");
expect(Object.keys(wrongCall)).toContain("item_ids");
});
it("start_index (snake_case) is WRONG for top-level param", () => {
const wrongCall = {
start_index: 0, // ❌ WRONG
};
expect(Object.keys(wrongCall)).not.toContain("startIndex");
expect(Object.keys(wrongCall)).toContain("start_index");
});
});
});
+7 -13
View File
@@ -10,7 +10,7 @@
*/ */
import { writable, get, derived } from "svelte/store"; import { writable, get, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { sessions, selectedSession } from "./sessions"; import { sessions, selectedSession } from "./sessions";
import { auth } from "./auth"; import { auth } from "./auth";
import { ticksToSeconds } from "$lib/utils/playbackUnits"; import { ticksToSeconds } from "$lib/utils/playbackUnits";
@@ -47,7 +47,7 @@ function createPlaybackModeStore() {
*/ */
async function refreshMode(): Promise<void> { async function refreshMode(): Promise<void> {
try { try {
const rustMode = await invoke<RustPlaybackMode>("playback_mode_get_current"); const rustMode = (await commands.playbackModeGetCurrent()) as RustPlaybackMode;
update((s) => ({ update((s) => ({
...s, ...s,
@@ -91,7 +91,7 @@ function createPlaybackModeStore() {
// Rust handles everything - just wait for it to complete // Rust handles everything - just wait for it to complete
// It includes its own 5-second timeout for track loading // It includes its own 5-second timeout for track loading
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId); console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId);
await invoke("playback_mode_transfer_to_remote", { sessionId }); await commands.playbackModeTransferToRemote(sessionId ?? "");
console.log("[PlaybackMode] Invoke completed successfully"); console.log("[PlaybackMode] Invoke completed successfully");
if (aborted) { if (aborted) {
@@ -192,9 +192,7 @@ function createPlaybackModeStore() {
// Use player_play_tracks - backend fetches all metadata from single ID // Use player_play_tracks - backend fetches all metadata from single ID
const repositoryHandle = repository.getHandle(); const repositoryHandle = repository.getHandle();
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds: [itemId], trackIds: [itemId],
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@@ -202,7 +200,6 @@ function createPlaybackModeStore() {
type: "search", type: "search",
searchQuery: "", searchQuery: "",
}, },
},
}); });
if (aborted) return; if (aborted) return;
@@ -212,16 +209,13 @@ function createPlaybackModeStore() {
// Seek to position if not at the very start // Seek to position if not at the very start
if (positionSeconds > 0.5) { if (positionSeconds > 0.5) {
await invoke("player_seek", { position: positionSeconds }); await commands.playerSeek(positionSeconds);
} }
if (aborted) return; if (aborted) return;
// Let Rust handle stopping remote playback // Let Rust handle stopping remote playback
await invoke("playback_mode_transfer_to_local", { await commands.playbackModeTransferToLocal(itemId, positionTicks);
currentItemId: itemId,
positionTicks,
});
if (aborted) return; if (aborted) return;
@@ -328,7 +322,7 @@ function createPlaybackModeStore() {
try { try {
// Notify Rust backend to switch to idle mode // Notify Rust backend to switch to idle mode
await invoke("playback_mode_set", { mode: { type: "idle" } }); await commands.playbackModeSet({ type: "idle" });
// Update local state // Update local state
sessions.selectSession(null); sessions.selectSession(null);
+11 -17
View File
@@ -7,8 +7,8 @@
// TRACES: UR-005, UR-015 | DR-005, DR-020 // TRACES: UR-005, UR-015 | DR-005, DR-020
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -72,7 +72,7 @@ function createQueueStore() {
*/ */
async function syncFromRust(): Promise<void> { async function syncFromRust(): Promise<void> {
try { try {
const rustQueue = await invoke<QueueChangedEvent>("player_get_queue"); const rustQueue = (await commands.playerGetQueue()) as unknown as QueueChangedEvent;
console.log("[Queue] Synced from Rust - items:", rustQueue.items.length); console.log("[Queue] Synced from Rust - items:", rustQueue.items.length);
set({ set({
items: rustQueue.items, items: rustQueue.items,
@@ -105,37 +105,37 @@ function createQueueStore() {
// TRACES: UR-005, UR-015 | DR-005 // TRACES: UR-005, UR-015 | DR-005
async function next() { async function next() {
await invoke("player_next"); await commands.playerNext();
} }
// TRACES: UR-005, UR-015 | DR-005 // TRACES: UR-005, UR-015 | DR-005
async function previous() { async function previous() {
await invoke("player_previous"); await commands.playerPrevious();
} }
// TRACES: UR-005, UR-015 | DR-005, DR-020 // TRACES: UR-005, UR-015 | DR-005, DR-020
async function skipTo(index: number) { async function skipTo(index: number) {
await invoke("player_skip_to", { index }); await commands.playerSkipTo(index);
} }
// TRACES: UR-005, UR-015 | DR-005 // TRACES: UR-005, UR-015 | DR-005
async function toggleShuffle() { async function toggleShuffle() {
await invoke("player_toggle_shuffle"); await commands.playerToggleShuffle();
} }
// TRACES: UR-005, UR-015 | DR-005 // TRACES: UR-005, UR-015 | DR-005
async function cycleRepeat() { async function cycleRepeat() {
await invoke("player_cycle_repeat"); await commands.playerCycleRepeat();
} }
// TRACES: UR-015 | DR-020 // TRACES: UR-015 | DR-020
async function removeFromQueue(index: number) { async function removeFromQueue(index: number) {
await invoke("player_remove_from_queue", { index }); await commands.playerRemoveFromQueue(index);
} }
// TRACES: UR-015 | DR-020 // TRACES: UR-015 | DR-020
async function moveInQueue(fromIndex: number, toIndex: number) { async function moveInQueue(fromIndex: number, toIndex: number) {
await invoke("player_move_in_queue", { fromIndex, toIndex }); await commands.playerMoveInQueue(fromIndex, toIndex);
} }
// TRACES: UR-015 | DR-020 // TRACES: UR-015 | DR-020
@@ -152,20 +152,14 @@ function createQueueStore() {
// Use new Rust commands that accept IDs only // Use new Rust commands that accept IDs only
if (trackIds.length === 1) { if (trackIds.length === 1) {
await invoke("player_add_track_by_id", { await commands.playerAddTrackById(repositoryHandle, {
repositoryHandle,
request: {
trackId: trackIds[0], trackId: trackIds[0],
position, position,
},
}); });
} else { } else {
await invoke("player_add_tracks_by_ids", { await commands.playerAddTracksByIds(repositoryHandle, {
repositoryHandle,
request: {
trackIds, trackIds,
position, position,
},
}); });
} }
} }
+11 -36
View File
@@ -2,8 +2,8 @@
// TRACES: UR-010 | DR-037 // TRACES: UR-010 | DR-037
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { commands } from "$lib/api/bindings";
import type { Session } from "$lib/api/types"; import type { Session } from "$lib/api/types";
interface SessionsState { interface SessionsState {
@@ -53,7 +53,7 @@ function createSessionsStore() {
try { try {
update((s) => ({ ...s, isLoading: true, error: null })); update((s) => ({ ...s, isLoading: true, error: null }));
const sessions = await invoke<Session[]>("sessions_poll_now"); const sessions = await commands.sessionsPollNow();
console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`); console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`);
sessions.forEach((s, i) => { sessions.forEach((s, i) => {
@@ -91,10 +91,7 @@ function createSessionsStore() {
*/ */
async function sendPlayPause(sessionId: string | null | undefined): Promise<void> { async function sendPlayPause(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await commands.remoteSendCommand(sessionId ?? "", "PlayPause");
sessionId,
command: "PlayPause",
});
// Refresh after command to get updated state // Refresh after command to get updated state
await refresh(); await refresh();
} catch (error) { } catch (error) {
@@ -108,10 +105,7 @@ function createSessionsStore() {
*/ */
async function sendStop(sessionId: string | null | undefined): Promise<void> { async function sendStop(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await commands.remoteSendCommand(sessionId ?? "", "Stop");
sessionId,
command: "Stop",
});
await refresh(); await refresh();
} catch (error) { } catch (error) {
console.error("Failed to send stop command:", error); console.error("Failed to send stop command:", error);
@@ -124,10 +118,7 @@ function createSessionsStore() {
*/ */
async function sendNext(sessionId: string | null | undefined): Promise<void> { async function sendNext(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await commands.remoteSendCommand(sessionId ?? "", "NextTrack");
sessionId,
command: "NextTrack",
});
await refresh(); await refresh();
} catch (error) { } catch (error) {
console.error("Failed to send next track command:", error); console.error("Failed to send next track command:", error);
@@ -140,10 +131,7 @@ function createSessionsStore() {
*/ */
async function sendPrevious(sessionId: string | null | undefined): Promise<void> { async function sendPrevious(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await commands.remoteSendCommand(sessionId ?? "", "PreviousTrack");
sessionId,
command: "PreviousTrack",
});
await refresh(); await refresh();
} catch (error) { } catch (error) {
console.error("Failed to send previous track command:", error); console.error("Failed to send previous track command:", error);
@@ -156,10 +144,7 @@ function createSessionsStore() {
*/ */
async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> { async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
try { try {
await invoke("remote_session_seek", { await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
sessionId,
positionTicks,
});
// Don't refresh immediately for seek to avoid UI lag // Don't refresh immediately for seek to avoid UI lag
} catch (error) { } catch (error) {
console.error("Failed to send seek command:", error); console.error("Failed to send seek command:", error);
@@ -172,10 +157,7 @@ function createSessionsStore() {
*/ */
async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> { async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> {
try { try {
await invoke("remote_session_set_volume", { await commands.remoteSessionSetVolume(sessionId ?? "", volume);
sessionId,
volume,
});
// Don't refresh immediately for volume to avoid UI lag // Don't refresh immediately for volume to avoid UI lag
} catch (error) { } catch (error) {
console.error("Failed to send volume command:", error); console.error("Failed to send volume command:", error);
@@ -188,10 +170,7 @@ function createSessionsStore() {
*/ */
async function sendToggleMute(sessionId: string | null | undefined): Promise<void> { async function sendToggleMute(sessionId: string | null | undefined): Promise<void> {
try { try {
await invoke("remote_send_command", { await commands.remoteSendCommand(sessionId ?? "", "ToggleMute");
sessionId,
command: "ToggleMute",
});
await refresh(); await refresh();
} catch (error) { } catch (error) {
console.error("Failed to toggle mute:", error); console.error("Failed to toggle mute:", error);
@@ -213,14 +192,10 @@ function createSessionsStore() {
console.log("[SESSIONS] itemIds.length:", itemIds.length); console.log("[SESSIONS] itemIds.length:", itemIds.length);
console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds)); console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds));
console.log("[SESSIONS] startIndex:", startIndex); console.log("[SESSIONS] startIndex:", startIndex);
console.log("[SESSIONS] About to call invoke('remote_play_on_session')"); console.log("[SESSIONS] About to call commands.remotePlayOnSession");
try { try {
// Use Rust player's Jellyfin client for remote playback // Use Rust player's Jellyfin client for remote playback
const result = await invoke("remote_play_on_session", { const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
sessionId,
itemIds,
startIndex,
});
console.log("[SESSIONS] invoke succeeded, result:", result); console.log("[SESSIONS] invoke succeeded, result:", result);
await refresh(); await refresh();
} catch (error) { } catch (error) {
+5 -11
View File
@@ -11,7 +11,7 @@
*/ */
import { writable, derived } from "svelte/store"; import { writable, derived } from "svelte/store";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
export type SleepTimerMode = export type SleepTimerMode =
| { kind: "off" } | { kind: "off" }
@@ -39,25 +39,19 @@ function createSleepTimerStore() {
// Methods that invoke the backend API // Methods that invoke the backend API
async setTimeTimer(minutes: number): Promise<void> { async setTimeTimer(minutes: number): Promise<void> {
const endTime = Date.now() + minutes * 60 * 1000; const endTime = Date.now() + minutes * 60 * 1000;
await invoke("player_set_sleep_timer", { await commands.playerSetSleepTimer({ kind: "time", endTime });
mode: { kind: "time", endTime },
});
}, },
async setEndOfTrackTimer(): Promise<void> { async setEndOfTrackTimer(): Promise<void> {
await invoke("player_set_sleep_timer", { await commands.playerSetSleepTimer({ kind: "endOfTrack" });
mode: { kind: "endOfTrack" },
});
}, },
async setEpisodesTimer(count: number): Promise<void> { async setEpisodesTimer(count: number): Promise<void> {
await invoke("player_set_sleep_timer", { await commands.playerSetSleepTimer({ kind: "episodes", remaining: count });
mode: { kind: "episodes", remaining: count },
});
}, },
async cancel(): Promise<void> { async cancel(): Promise<void> {
await invoke("player_cancel_sleep_timer"); await commands.playerCancelSleepTimer();
}, },
}; };
} }
-255
View File
@@ -1,255 +0,0 @@
/**
* Unit tests for Tauri command parameter names
*
* CRITICAL: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* ALL parameters (top-level and nested) use camelCase on the frontend side.
*
* @see https://v2.tauri.app/develop/calling-rust/
*/
import { describe, it, expect } from 'vitest';
describe("Tauri Command Parameter Names - Critical Pattern Test", () => {
describe("All command parameters use camelCase (Tauri v2 auto-converts)", () => {
it("player_play_tracks: repositoryHandle (NOT repository_handle)", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "test" }
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
expect(params.repositoryHandle).toBe("handle-123");
});
it("playback_mode_transfer_to_local: currentItemId & positionTicks", () => {
const params = {
currentItemId: "item-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("currentItemId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("current_item_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("pin_item/unpin_item: itemId (NOT item_id)", () => {
const params = { itemId: "id-123" };
expect(Object.keys(params)).toContain("itemId");
expect(Object.keys(params)).not.toContain("item_id");
});
it("remote_send_command: sessionId (NOT session_id)", () => {
const params = {
sessionId: "session-123",
command: "PlayPause"
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).not.toContain("session_id");
});
it("remote_play_on_session: sessionId, itemIds, startIndex", () => {
const params = {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).toContain("startIndex");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("item_ids");
expect(Object.keys(params)).not.toContain("start_index");
});
it("remote_session_seek: sessionId & positionTicks", () => {
const params = {
sessionId: "session-123",
positionTicks: 50000
};
expect(Object.keys(params)).toContain("sessionId");
expect(Object.keys(params)).toContain("positionTicks");
expect(Object.keys(params)).not.toContain("session_id");
expect(Object.keys(params)).not.toContain("position_ticks");
});
it("player_add_track_by_id: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackId: "id1",
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_add_tracks_by_ids: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
trackIds: ["id1", "id2"],
position: 0
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("player_play_album_track: repositoryHandle", () => {
const params = {
repositoryHandle: "handle-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
}
};
expect(Object.keys(params)).toContain("repositoryHandle");
expect(Object.keys(params)).not.toContain("repository_handle");
});
it("playlist_create: handle, name, itemIds (NOT item_ids)", () => {
const params = {
handle: "handle-123",
name: "My Playlist",
itemIds: ["track1", "track2"],
};
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).not.toContain("item_ids");
});
it("playlist_get_items: handle, playlistId (NOT playlist_id)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
};
expect(Object.keys(params)).toContain("playlistId");
expect(Object.keys(params)).not.toContain("playlist_id");
});
it("playlist_add_items: playlistId, itemIds", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
itemIds: ["t1", "t2"],
};
expect(Object.keys(params)).toContain("playlistId");
expect(Object.keys(params)).toContain("itemIds");
expect(Object.keys(params)).not.toContain("playlist_id");
expect(Object.keys(params)).not.toContain("item_ids");
});
it("playlist_remove_items: playlistId, entryIds (NOT entry_ids)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
entryIds: ["e1", "e2"],
};
expect(Object.keys(params)).toContain("entryIds");
expect(Object.keys(params)).not.toContain("entry_ids");
});
it("playlist_move_item: playlistId, itemId, newIndex (NOT new_index)", () => {
const params = {
handle: "handle-123",
playlistId: "pl-123",
itemId: "track1",
newIndex: 2,
};
expect(Object.keys(params)).toContain("newIndex");
expect(Object.keys(params)).toContain("itemId");
expect(Object.keys(params)).not.toContain("new_index");
expect(Object.keys(params)).not.toContain("item_id");
});
});
describe("Nested struct fields also use camelCase (via serde rename_all)", () => {
it("PlayTracksRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test query"
}
};
expect(Object.keys(request)).toContain("trackIds");
expect(Object.keys(request)).toContain("startIndex");
expect(request.context.searchQuery).toBe("test query");
});
it("PlayAlbumTrackRequest with #[serde(rename_all = camelCase)]", () => {
const request = {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false
};
expect(Object.keys(request)).toContain("albumId");
expect(Object.keys(request)).toContain("albumName");
expect(Object.keys(request)).toContain("trackId");
});
it("PlayTracksContext variants with correct field names", () => {
const searchContext = {
type: "search",
searchQuery: "test"
};
const playlistContext = {
type: "playlist",
playlistId: "pl-123",
playlistName: "My Playlist"
};
const customContext = {
type: "custom",
label: "Custom Queue"
};
expect(searchContext.searchQuery).toBe("test");
expect(playlistContext.playlistId).toBe("pl-123");
expect(playlistContext.playlistName).toBe("My Playlist");
expect(customContext.label).toBe("Custom Queue");
});
});
describe("Error cases - what NOT to do", () => {
it("WRONG: snake_case top-level params will fail", () => {
// ❌ This will cause "invalid args request" error
const wrongParams = {
repository_handle: "handle-123", // ❌ WRONG - should be repositoryHandle
session_id: "session-123", // ❌ WRONG - should be sessionId
item_ids: ["id1"] // ❌ WRONG - should be itemIds
};
// Verify we understand what's wrong
expect(Object.keys(wrongParams)).not.toContain("repositoryHandle");
expect(Object.keys(wrongParams)).toContain("repository_handle");
});
});
});
-387
View File
@@ -1,387 +0,0 @@
/**
* Integration test: Tauri command invocations
*
* This test validates that invoke parameters use the correct naming convention.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
* All top-level parameters must use camelCase.
*
* RUN THIS BEFORE DEPLOYING:
* ```bash
* npm test -- tauriIntegration.test.ts
* ```
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
/**
* Mock Tauri invoke to capture actual calls from production code
*/
interface InvokeCall {
command: string;
args: Record<string, any>;
timestamp: number;
}
let invokeHistory: InvokeCall[] = [];
let invokeErrors: Map<string, Error> = new Map();
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
const callArgs = args || {};
invokeHistory.push({
command,
args: callArgs,
timestamp: Date.now(),
});
// Check if this command should error
const error = invokeErrors.get(command);
if (error) {
throw error;
}
// Simulate Tauri command success
return { success: true };
});
/**
* Expected command signatures - the source of truth
* All parameter names are camelCase (Tauri v2 auto-converts from Rust snake_case)
*/
const COMMAND_SPECS: Record<
string,
{
requiredParams: string[];
description: string;
}
> = {
player_play_tracks: {
requiredParams: ["repositoryHandle", "request"],
description: "Play a set of tracks",
},
playback_mode_set: {
requiredParams: ["mode"],
description: "Set playback mode",
},
playback_mode_transfer_to_local: {
requiredParams: ["currentItemId", "positionTicks"],
description: "Transfer playback to local device",
},
remote_send_command: {
requiredParams: ["sessionId", "command"],
description: "Send command to remote session",
},
remote_play_on_session: {
requiredParams: ["sessionId", "itemIds", "startIndex"],
description: "Play items on remote session",
},
remote_session_seek: {
requiredParams: ["sessionId", "positionTicks"],
description: "Seek on remote session",
},
pin_item: {
requiredParams: ["itemId"],
description: "Pin an item for download",
},
unpin_item: {
requiredParams: ["itemId"],
description: "Unpin an item",
},
player_add_track_by_id: {
requiredParams: ["repositoryHandle", "request"],
description: "Add track to queue by ID",
},
player_add_tracks_by_ids: {
requiredParams: ["repositoryHandle", "request"],
description: "Add tracks to queue by IDs",
},
player_play_album_track: {
requiredParams: ["repositoryHandle", "request"],
description: "Play track from album",
},
};
/**
* Validate an invoke call against the spec
*/
function validateInvokeCall(call: InvokeCall): { valid: boolean; errors: string[] } {
const errors: string[] = [];
const spec = COMMAND_SPECS[call.command];
if (!spec) {
errors.push(`Unknown command: ${call.command}`);
return { valid: errors.length === 0, errors };
}
// Check all required parameters are present
for (const paramName of spec.requiredParams) {
if (!(paramName in call.args)) {
errors.push(
`Missing required parameter "${paramName}" for ${call.command}`
);
}
}
// Check for snake_case violations (should be camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_ids: "itemIds",
start_index: "startIndex",
item_id: "itemId",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (snakeCase in call.args) {
errors.push(
`Found snake_case "${snakeCase}" instead of "${camelCase}" for ${call.command}`
);
}
}
return {
valid: errors.length === 0,
errors,
};
}
describe("Tauri Integration - Command Invocations", () => {
beforeEach(() => {
invokeHistory = [];
invokeErrors.clear();
// Mock Tauri invoke in the context where it will be imported
vi.doMock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
});
afterEach(() => {
vi.clearAllMocks();
invokeHistory = [];
invokeErrors.clear();
});
describe("playbackMode store", () => {
it("should invoke player_play_tracks with correct parameter names", async () => {
await mockInvoke("player_play_tracks", {
repositoryHandle: "test-repo",
request: {
trackIds: ["id1", "id2"],
startIndex: 0,
shuffle: false,
context: {
type: "search",
searchQuery: "test",
},
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
if (!validation.valid) {
throw new Error(
`player_play_tracks invocation failed validation:\n${validation.errors.join("\n")}`
);
}
// Verify the actual parameters
expect(lastCall.command).toBe("player_play_tracks");
expect(lastCall.args).toHaveProperty("repositoryHandle");
expect(lastCall.args.repositoryHandle).toBe("test-repo");
expect(lastCall.args.request).toHaveProperty("trackIds");
expect(lastCall.args.request).toHaveProperty("context");
expect(lastCall.args.request.context).toHaveProperty("searchQuery");
});
it("should NOT use repository_handle - must be repositoryHandle", async () => {
// This test documents the WRONG way
const wrongCall: InvokeCall = {
command: "player_play_tracks",
args: {
repository_handle: "test-repo", // ❌ WRONG - snake_case
request: {},
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
// This should fail because the parameter name is wrong
expect(validation.valid).toBe(false);
expect(validation.errors.some((e) => e.includes("repository_handle"))).toBe(true);
});
it("should invoke playback_mode_transfer_to_local with correct parameters", async () => {
await mockInvoke("playback_mode_transfer_to_local", {
currentItemId: "item-123",
positionTicks: 50000,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("currentItemId");
expect(lastCall.args).toHaveProperty("positionTicks");
});
});
describe("sessions store", () => {
it("should invoke remote_send_command with sessionId parameter", async () => {
await mockInvoke("remote_send_command", {
sessionId: "session-123", // ✓ Correct
command: "PlayPause",
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).not.toHaveProperty("session_id");
});
it("should NOT use session_id - must be sessionId", async () => {
const wrongCall: InvokeCall = {
command: "remote_send_command",
args: {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
it("should invoke remote_play_on_session with correct parameters", async () => {
await mockInvoke("remote_play_on_session", {
sessionId: "session-123",
itemIds: ["id1", "id2"],
startIndex: 0,
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("sessionId");
expect(lastCall.args).toHaveProperty("itemIds");
expect(lastCall.args).toHaveProperty("startIndex");
});
});
describe("downloads store", () => {
it("should invoke pin_item with itemId parameter", async () => {
await mockInvoke("pin_item", {
itemId: "item-123", // ✓ Correct
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("itemId");
});
it("should NOT use item_id - must be itemId", async () => {
const wrongCall: InvokeCall = {
command: "pin_item",
args: {
item_id: "item-123", // ❌ WRONG
},
timestamp: Date.now(),
};
const validation = validateInvokeCall(wrongCall);
expect(validation.valid).toBe(false);
});
});
describe("queue store", () => {
it("should invoke player_add_track_by_id with repositoryHandle", async () => {
await mockInvoke("player_add_track_by_id", {
repositoryHandle: "repo-123",
request: {
trackId: "track-123",
position: 0,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args).toHaveProperty("repositoryHandle");
});
it("should invoke player_play_album_track with correct parameters", async () => {
await mockInvoke("player_play_album_track", {
repositoryHandle: "repo-123",
request: {
albumId: "album-123",
albumName: "Test Album",
trackId: "track-123",
shuffle: false,
},
});
const lastCall = invokeHistory[invokeHistory.length - 1];
const validation = validateInvokeCall(lastCall);
expect(validation.valid).toBe(true);
expect(lastCall.args.request).toHaveProperty("albumId");
expect(lastCall.args.request).toHaveProperty("albumName");
expect(lastCall.args.request).toHaveProperty("trackId");
});
});
describe("Comprehensive validation", () => {
it("should validate all recorded invoke calls", async () => {
// Simulate multiple invoke calls from different parts of the app
await mockInvoke("player_play_tracks", {
repositoryHandle: "repo1",
request: { trackIds: ["1"], startIndex: 0, shuffle: false, context: { type: "search", searchQuery: "" } },
});
await mockInvoke("pin_item", {
itemId: "item1",
});
await mockInvoke("remote_send_command", {
sessionId: "session1",
command: "PlayPause",
});
// Validate ALL calls
const validationResults = invokeHistory.map((call) => ({
command: call.command,
validation: validateInvokeCall(call),
}));
const failures = validationResults.filter((r) => !r.validation.valid);
if (failures.length > 0) {
const errorMessages = failures
.map(
(f) =>
`${f.command}: ${f.validation.errors.join("; ")}`
)
.join("\n");
throw new Error(
`Found ${failures.length} invalid invoke calls:\n${errorMessages}`
);
}
expect(failures).toHaveLength(0);
expect(invokeHistory).toHaveLength(3);
});
});
});
-348
View File
@@ -1,348 +0,0 @@
/**
* Debug test - Simulate actual Tauri invoke calls locally
*
* This test validates Tauri command invocations to catch
* "invalid args request" errors before they hit the Android app.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock implementation that validates JSON structure
const createDebugInvoke = () => {
const calls: Array<{ command: string; args: Record<string, any>; json: string }> = [];
const invoke = async (command: string, args?: Record<string, any>) => {
const argsToSend = args || {};
const json = JSON.stringify(argsToSend);
console.log(`[INVOKE] ${command}`);
console.log(`[JSON] ${json}`);
calls.push({ command, args: argsToSend, json });
// Validate parameter names match what Tauri v2 expects (camelCase)
validateCommandParameters(command, argsToSend);
return { success: true };
};
const validateCommandParameters = (command: string, args: Record<string, any>) => {
const paramNames = Object.keys(args);
// Check for snake_case violations (Tauri v2 expects camelCase)
const snakeCaseViolations: Record<string, string> = {
repository_handle: "repositoryHandle",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
session_id: "sessionId",
item_id: "itemId",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [snakeCase, camelCase] of Object.entries(snakeCaseViolations)) {
if (paramNames.includes(snakeCase)) {
throw new Error(
`[VALIDATION ERROR] ${command} has "${snakeCase}" (snake_case)\n` +
`Should be "${camelCase}" (camelCase)\n` +
`Tauri v2 auto-converts Rust snake_case to camelCase for the frontend!`
);
}
}
switch (command) {
case "player_play_tracks":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] player_play_tracks missing "repositoryHandle"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "playback_mode_transfer_to_local":
if (!paramNames.includes("currentItemId")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "currentItemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] playback_mode_transfer_to_local missing "positionTicks"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "pin_item":
case "unpin_item":
if (!paramNames.includes("itemId")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "itemId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_send_command":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_send_command missing "sessionId"\n` +
`Found: ${paramNames.join(", ")}\n` +
`This will cause "invalid args request" error on Android!`
);
}
break;
case "remote_play_on_session":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "sessionId"`
);
}
if (!paramNames.includes("itemIds")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "itemIds"`
);
}
if (!paramNames.includes("startIndex")) {
throw new Error(
`[VALIDATION ERROR] remote_play_on_session missing "startIndex"`
);
}
break;
case "remote_session_seek":
if (!paramNames.includes("sessionId")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "sessionId"`
);
}
if (!paramNames.includes("positionTicks")) {
throw new Error(
`[VALIDATION ERROR] remote_session_seek missing "positionTicks"`
);
}
break;
case "player_add_track_by_id":
case "player_add_tracks_by_ids":
case "player_play_album_track":
if (!paramNames.includes("repositoryHandle")) {
throw new Error(
`[VALIDATION ERROR] ${command} missing "repositoryHandle"`
);
}
break;
}
};
return { invoke, getCalls: () => calls };
};
describe("Tauri Invoke Debug - Catch Android 'invalid args request' errors", () => {
let debugInvoke: Awaited<ReturnType<typeof createDebugInvoke>>;
beforeEach(() => {
debugInvoke = createDebugInvoke();
});
describe("Parameter validation - exact Android behavior", () => {
it("player_play_tracks with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repositoryHandle: "test-handle", // ✓ CORRECT
request: {
trackIds: ["id1"],
startIndex: 0,
shuffle: false,
context: { type: "search", searchQuery: "" },
},
})
).resolves.toEqual({ success: true });
});
it("player_play_tracks with WRONG repository_handle throws validation error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "test-handle", // ❌ WRONG - snake_case
request: {},
})
).rejects.toThrow(/snake_case/);
});
it("playback_mode_transfer_to_local with CORRECT parameters passes validation", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
currentItemId: "item-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("playback_mode_transfer_to_local with snake_case throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("playback_mode_transfer_to_local", {
current_item_id: "item-123", // ❌ WRONG
position_ticks: 50000, // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("pin_item with CORRECT itemId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
itemId: "id-123", // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("pin_item with snake_case item_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("pin_item", {
item_id: "id-123", // ❌ WRONG
})
).rejects.toThrow(/snake_case/);
});
it("remote_send_command with CORRECT sessionId passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
sessionId: "session-123", // ✓ CORRECT
command: "PlayPause",
})
).resolves.toEqual({ success: true });
});
it("remote_send_command with snake_case session_id throws error", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_send_command", {
session_id: "session-123", // ❌ WRONG
command: "PlayPause",
})
).rejects.toThrow(/snake_case/);
});
it("remote_play_on_session with all CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_play_on_session", {
sessionId: "session-123", // ✓ CORRECT
itemIds: ["id1", "id2"], // ✓ CORRECT
startIndex: 0, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("remote_session_seek with CORRECT parameters passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("remote_session_seek", {
sessionId: "session-123", // ✓ CORRECT
positionTicks: 50000, // ✓ CORRECT
})
).resolves.toEqual({ success: true });
});
it("player_add_track_by_id with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_add_track_by_id", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: { trackId: "id1", position: 0 },
})
).resolves.toEqual({ success: true });
});
it("player_play_album_track with CORRECT repositoryHandle passes", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_album_track", {
repositoryHandle: "handle-123", // ✓ CORRECT
request: {
albumId: "album-123",
albumName: "Test",
trackId: "track-123",
shuffle: false,
},
})
).resolves.toEqual({ success: true });
});
});
describe("Track all invoke calls", () => {
it("records all invoke calls for debugging", async () => {
const { invoke, getCalls } = debugInvoke;
await invoke("player_play_tracks", {
repositoryHandle: "h1",
request: {},
});
await invoke("pin_item", {
itemId: "id1",
});
const calls = getCalls();
expect(calls).toHaveLength(2);
expect(calls[0].command).toBe("player_play_tracks");
expect(calls[1].command).toBe("pin_item");
// Each call should have valid JSON
calls.forEach((call) => {
expect(() => JSON.parse(call.json)).not.toThrow();
});
});
});
describe("Detailed error messages for debugging", () => {
it("provides clear error when parameter is missing", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
request: {}, // Missing repositoryHandle
})
).rejects.toThrow(/missing "repositoryHandle"/);
});
it("detects snake_case and suggests camelCase fix", async () => {
const { invoke } = debugInvoke;
await expect(
invoke("player_play_tracks", {
repository_handle: "h1", // Wrong!
request: {},
})
).rejects.toThrow(/snake_case/);
});
});
});
-107
View File
@@ -1,107 +0,0 @@
/**
* Test real Tauri invoke calls from production stores
*
* This test imports the actual production code and validates
* that it's sending the correct parameter names to Tauri.
* It catches real bugs that would fail on Android.
*
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
* snake_case Rust parameter names to camelCase for the frontend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
let capturedInvokes: Array<{ command: string; args: Record<string, any> }> = [];
// Mock Tauri BEFORE importing stores
const mockInvoke = vi.fn(async (command: string, args?: Record<string, any>) => {
capturedInvokes.push({ command, args: args || {} });
return { success: true };
});
vi.mock("@tauri-apps/api/core", () => ({
invoke: mockInvoke,
}));
describe("Real production code - Tauri invoke calls", () => {
beforeEach(() => {
capturedInvokes = [];
vi.clearAllMocks();
});
describe("playbackMode store - real calls", () => {
it("playbackMode store should be importable and defined", async () => {
const { playbackMode } = await import("../stores/playbackMode");
try {
expect(playbackMode).toBeDefined();
expect(playbackMode.subscribe).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock, but at least we tried
}
});
});
describe("sessions store - real calls", () => {
it("should send sessionId, NOT session_id", async () => {
const { sessions } = await import("../stores/sessions");
try {
expect(sessions).toBeDefined();
expect(sessions.sendPlayPause).toBeDefined();
} catch (e) {
// Store might have dependencies we can't mock
}
});
});
describe("downloads store - real calls", () => {
it("should send itemId, NOT item_id", async () => {
const { downloads } = await import("../stores/downloads");
try {
expect(downloads).toBeDefined();
expect(downloads.pinItem).toBeDefined();
expect(downloads.unpinItem).toBeDefined();
} catch (e) {
// Store might have dependencies
}
});
});
describe("captured invoke calls validation", () => {
it("validates all captured invokes have correct parameter names", () => {
// After running other tests, validate captured calls
for (const invoke of capturedInvokes) {
validateInvokeCall(invoke);
}
});
});
});
/**
* Validate a single invoke call for correct parameter naming
*/
function validateInvokeCall(invoke: { command: string; args: Record<string, any> }) {
const { command, args } = invoke;
// Check for snake_case violations in top-level params
// Tauri v2 expects camelCase (auto-converts from Rust snake_case)
const violations: Record<string, string> = {
repository_handle: "repositoryHandle",
session_id: "sessionId",
item_id: "itemId",
current_item_id: "currentItemId",
position_ticks: "positionTicks",
item_ids: "itemIds",
start_index: "startIndex",
};
for (const [wrongName, correctName] of Object.entries(violations)) {
if (wrongName in args) {
throw new Error(
`[${command}] Found top-level parameter "${wrongName}" (snake_case) - should be "${correctName}" (camelCase)`
);
}
}
}
-1
View File
@@ -3,7 +3,6 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { invoke } from "@tauri-apps/api/core";
import "../app.css"; import "../app.css";
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth"; import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity"; import { connectivity, isConnected } from "$lib/stores/connectivity";
+3 -3
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { downloads, activeDownloads, completedDownloads, pendingDownloads, failedDownloads } from "$lib/stores/downloads"; import { downloads, activeDownloads, completedDownloads, pendingDownloads, failedDownloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte"; import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
@@ -85,7 +85,7 @@
try { try {
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
await invoke("clear_stale_downloads", { userId }); await commands.clearStaleDownloads(userId);
await downloads.refresh(userId); await downloads.refresh(userId);
} }
} catch (error) { } catch (error) {
@@ -224,7 +224,7 @@
const userId = $auth.user?.id; const userId = $auth.user?.id;
if (userId) { if (userId) {
if (confirm('Delete ALL downloads (including completed)? This cannot be undone.')) { if (confirm('Delete ALL downloads (including completed)? This cannot be undone.')) {
await invoke("delete_all_downloads", { userId }); await commands.deleteAllDownloads(userId);
await downloads.refresh(userId); await downloads.refresh(userId);
} }
} }
+2 -9
View File
@@ -2,7 +2,7 @@
import { onMount, onDestroy, setContext } from "svelte"; import { onMount, onDestroy, setContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth"; import { auth, isAuthenticated, isLoading as isAuthLoading, currentUser } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
@@ -66,14 +66,7 @@
async function updateQueueStatus() { async function updateQueueStatus() {
try { try {
const queue = await invoke<{ const queue = await commands.playerGetQueue();
items: any[];
currentIndex: number | null;
hasNext: boolean;
hasPrevious: boolean;
shuffle: boolean;
repeat: string;
}>("player_get_queue");
// Reset failure counter on success // Reset failure counter on success
failedAttempts = 0; failedAttempts = 0;
+3 -9
View File
@@ -2,7 +2,7 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types"; import type { MediaItem, Library } from "$lib/api/types";
import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library"; import { library, libraryItems, isLibraryLoading, currentLibrary, libraries } from "$lib/stores/library";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
@@ -221,14 +221,11 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
const firstTrack = $libraryItems[0]; const firstTrack = $libraryItems[0];
await invoke("player_play_album_track", { await commands.playerPlayAlbumTrack(repositoryHandle, {
repositoryHandle,
request: {
albumId: item.id, albumId: item.id,
albumName: item.name, albumName: item.name,
trackId: firstTrack.id, trackId: firstTrack.id,
shuffle: false, shuffle: false,
},
}); });
} catch (e) { } catch (e) {
console.error("Failed to play album:", e); console.error("Failed to play album:", e);
@@ -248,14 +245,11 @@
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
// Pick a random track to start with // Pick a random track to start with
const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)]; const randomTrack = $libraryItems[Math.floor(Math.random() * $libraryItems.length)];
await invoke("player_play_album_track", { await commands.playerPlayAlbumTrack(repositoryHandle, {
repositoryHandle,
request: {
albumId: item.id, albumId: item.id,
albumName: item.name, albumName: item.name,
trackId: randomTrack.id, trackId: randomTrack.id,
shuffle: true, shuffle: true,
},
}); });
} catch (e) { } catch (e) {
console.error("Failed to shuffle play album:", e); console.error("Failed to shuffle play album:", e);
+53 -56
View File
@@ -2,7 +2,9 @@
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { invoke, convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem } from "$lib/api/types"; import type { MediaItem } from "$lib/api/types";
import { auth } from "$lib/stores/auth"; import { auth } from "$lib/stores/auth";
import { library } from "$lib/stores/library"; import { library } from "$lib/stores/library";
@@ -23,6 +25,9 @@
const itemId = $derived($page.params.id); const itemId = $derived($page.params.id);
const queueParam = $derived($page.url.searchParams.get("queue")); const queueParam = $derived($page.url.searchParams.get("queue"));
const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true"); const shuffleParam = $derived($page.url.searchParams.get("shuffle") === "true");
// When advancing to a next episode we always start from the beginning,
// even if the episode was previously started or watched.
const restartParam = $derived($page.url.searchParams.get("restart") === "true");
// Derive playback context from URL query params // Derive playback context from URL query params
const playbackContext = $derived.by(() => { const playbackContext = $derived.by(() => {
@@ -78,9 +83,12 @@
// Load when itemId changes (handles both initial load and navigation) // Load when itemId changes (handles both initial load and navigation)
$effect(() => { $effect(() => {
const id = itemId; const id = itemId;
const restart = restartParam;
if (id && id !== loadedItemId) { if (id && id !== loadedItemId) {
console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ")"); console.log("[AutoPlay] $effect triggered: loading new item", id, "(was:", loadedItemId, ") restart:", restart);
loadAndPlay(id); // restart=true (advancing to next episode) forces start-from-beginning,
// bypassing the resume-progress check.
loadAndPlay(id, restart ? 0 : undefined, restart);
} }
}); });
@@ -94,7 +102,7 @@
} }
}); });
async function loadAndPlay(id: string, startPosition?: number) { async function loadAndPlay(id: string, startPosition?: number, forceRestart = false) {
loading = true; loading = true;
error = null; error = null;
loadedItemId = id; loadedItemId = id;
@@ -116,16 +124,18 @@
} }
// If this track is already playing in the backend, just show the UI // If this track is already playing in the backend, just show the UI
// without restarting playback (e.g., when expanding from MiniPlayer) // without restarting playback (e.g., when expanding from MiniPlayer).
// forceRestart bypasses this so advancing to the next episode always
// restarts from the beginning even if it were already loaded.
const alreadyPlayingMedia = get(storeCurrentMedia); const alreadyPlayingMedia = get(storeCurrentMedia);
if (alreadyPlayingMedia?.id === id && !startPosition) { if (alreadyPlayingMedia?.id === id && !startPosition && !forceRestart) {
console.log("loadAndPlay: Track already playing, showing UI without restarting"); console.log("loadAndPlay: Track already playing, showing UI without restarting");
isVideo = item.type === "Movie" || item.type === "Episode"; isVideo = item.type === "Movie" || item.type === "Episode";
isPlaying = true; isPlaying = true;
loading = false; loading = false;
// Sync queue status // Sync queue status
try { try {
const queueStatus = await invoke<{ hasNext: boolean; hasPrevious: boolean }>("player_get_queue"); const queueStatus = await commands.playerGetQueue();
hasNext = queueStatus.hasNext; hasNext = queueStatus.hasNext;
hasPrevious = queueStatus.hasPrevious; hasPrevious = queueStatus.hasPrevious;
} catch (e) { } catch (e) {
@@ -145,7 +155,7 @@
// This prevents audio from continuing in the background and clears stale state // This prevents audio from continuing in the background and clears stale state
if (isVideo) { if (isVideo) {
try { try {
await invoke("player_stop"); await commands.playerStop();
queue.clear(); queue.clear();
console.log("loadAndPlay: Stopped audio backend for video playback"); console.log("loadAndPlay: Stopped audio backend for video playback");
} catch (e) { } catch (e) {
@@ -153,16 +163,15 @@
} }
} }
// Check for saved progress if no start position specified // Check for saved progress if no start position specified.
// When forceRestart is set (advancing to a next episode) we always start
// from the beginning, skipping the resume check and resume dialog.
const userId = auth.getUserId(); const userId = auth.getUserId();
console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition); console.log("Resume check - userId:", userId, "itemId:", id, "startPosition:", startPosition, "forceRestart:", forceRestart);
if (!startPosition && userId) { if (!startPosition && !forceRestart && userId) {
try { try {
const progress = await invoke<{ positionTicks: number } | null>( const progress = await commands.storageGetPlaybackProgress(userId, id);
"storage_get_playback_progress",
{ userId, itemId: id }
);
console.log("Resume check - retrieved progress:", progress); console.log("Resume check - retrieved progress:", progress);
if (progress && progress.positionTicks > 0 && item.runTimeTicks) { if (progress && progress.positionTicks > 0 && item.runTimeTicks) {
@@ -208,7 +217,7 @@
isOfflinePlayback = true; isOfflinePlayback = true;
// Get the storage path and construct full file path // Get the storage path and construct full file path
const storagePath = await invoke<string>("storage_get_path"); const storagePath = await commands.storageGetPath();
const fullPath = `${storagePath}/${localDownload.filePath}`; const fullPath = `${storagePath}/${localDownload.filePath}`;
console.log("loadAndPlay: Full local path:", fullPath); console.log("loadAndPlay: Full local path:", fullPath);
@@ -230,9 +239,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds: [item.id], trackIds: [item.id],
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@@ -240,10 +247,9 @@
type: "search", type: "search",
searchQuery: "", searchQuery: "",
}, },
},
}); });
if (startPosition) { if (startPosition) {
await invoke("player_seek", { position: startPosition }); await commands.playerSeek(startPosition);
} }
} }
} else { } else {
@@ -336,13 +342,11 @@
})); }));
// Use player_play_queue to set up the backend queue // Use player_play_queue to set up the backend queue
await invoke("player_play_queue", { await commands.playerPlayQueue({
request: {
items: queueItems, items: queueItems,
startIndex: actualStartIndex, startIndex: actualStartIndex,
shuffle: shuffleParam, shuffle: shuffleParam,
}, } as unknown as PlayQueueRequest);
});
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
console.log("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks"); console.log("loadAndPlay: Successfully set up queue with", audioTracks.length, "tracks");
@@ -353,9 +357,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds: [item.id], trackIds: [item.id],
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@@ -363,7 +365,6 @@
type: "search", type: "search",
searchQuery: "", searchQuery: "",
}, },
},
}); });
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
@@ -375,9 +376,7 @@
const repo = auth.getRepository(); const repo = auth.getRepository();
const repositoryHandle = repo.getHandle(); const repositoryHandle = repo.getHandle();
await invoke("player_play_tracks", { await commands.playerPlayTracks(repositoryHandle, {
repositoryHandle,
request: {
trackIds: [item.id], trackIds: [item.id],
startIndex: 0, startIndex: 0,
shuffle: false, shuffle: false,
@@ -385,7 +384,6 @@
type: "search", type: "search",
searchQuery: "", searchQuery: "",
}, },
},
}); });
// Queue will auto-update from Rust backend event // Queue will auto-update from Rust backend event
@@ -394,7 +392,7 @@
// Seek to start position if provided // Seek to start position if provided
if (startPosition) { if (startPosition) {
await invoke("player_seek", { position: startPosition }); await commands.playerSeek(startPosition);
} }
} }
} }
@@ -442,24 +440,17 @@
async function updateStatus() { async function updateStatus() {
try { try {
const status = await invoke<{ const status = await commands.playerGetStatus();
state: { kind: string; position?: number; duration?: number };
shuffle: boolean;
repeat: string;
}>("player_get_status");
if (status.state.kind === "playing" || status.state.kind === "paused") { if (status.state.kind === "playing" || status.state.kind === "paused") {
isPlaying = status.state.kind === "playing"; isPlaying = status.state.kind === "playing";
// Note: position/duration are now derived from player store (updated by events) // Note: position/duration are now derived from player store (updated by events)
} }
shuffle = status.shuffle; shuffle = status.shuffle;
repeat = status.repeat as "off" | "all" | "one"; repeat = status.repeat;
// Update queue status // Update queue status
const queue = await invoke<{ const queue = await commands.playerGetQueue();
hasNext: boolean;
hasPrevious: boolean;
}>("player_get_queue");
hasNext = queue.hasNext; hasNext = queue.hasNext;
hasPrevious = queue.hasPrevious; hasPrevious = queue.hasPrevious;
} catch (e) { } catch (e) {
@@ -490,23 +481,30 @@
} }
// Playback reporting callbacks // Playback reporting callbacks
function handleReportStart(positionSeconds: number) { //
const id = itemId; // These receive the reporting item id from the VideoPlayer (its own media.id),
// NOT the live URL param (itemId). During autoplay the URL flips to the next
// episode before the outgoing VideoPlayer's onDestroy fires its final
// reportStop. Keying off itemId would stamp the old episode's near-end
// position onto the new episode, making it resume at ~99% (or pop the resume
// dialog). The VideoPlayer always knows which media it actually played.
function handleReportStart(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
const context = playbackContext; // playbackContext is a derived value, not a function const context = playbackContext; // playbackContext is a derived value, not a function
if (id) { if (id) {
reportPlaybackStart(id, positionSeconds, context.type, context.id); reportPlaybackStart(id, positionSeconds, context.type, context.id);
} }
} }
function handleReportProgress(positionSeconds: number, isPaused: boolean) { function handleReportProgress(positionSeconds: number, isPaused: boolean, reportId?: string) {
const id = itemId; const id = reportId ?? itemId;
if (id) { if (id) {
reportPlaybackProgress(id, positionSeconds, isPaused); reportPlaybackProgress(id, positionSeconds, isPaused);
} }
} }
function handleReportStop(positionSeconds: number) { function handleReportStop(positionSeconds: number, reportId?: string) {
const id = itemId; const id = reportId ?? itemId;
if (id) { if (id) {
reportPlaybackStopped(id, positionSeconds); reportPlaybackStopped(id, positionSeconds);
} }
@@ -522,10 +520,7 @@
try { try {
const repo = auth.getRepository(); const repo = auth.getRepository();
const repoHandle = repo.getHandle(); const repoHandle = repo.getHandle();
await invoke("player_on_playback_ended", { await commands.playerOnPlaybackEnded(mediaId, repoHandle);
itemId: mediaId,
repositoryHandle: repoHandle,
});
} catch (e) { } catch (e) {
console.error("[AutoPlay] Failed to handle playback ended:", e); console.error("[AutoPlay] Failed to handle playback ended:", e);
} }
@@ -560,8 +555,10 @@
function handleSkipToNextEpisode() { function handleSkipToNextEpisode() {
if (nextEpisode) { if (nextEpisode) {
// Use replaceState so "close/back" returns to the library, not the previous episode // Use replaceState so "close/back" returns to the library, not the previous episode.
goto(`/player/${nextEpisode.id}`, { replaceState: true }); // restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
goto(`/player/${nextEpisode.id}?restart=true`, { replaceState: true });
} }
} }
+6 -20
View File
@@ -1,7 +1,8 @@
<!-- TRACES: UR-023 | DR-048 --> <!-- TRACES: UR-023 | DR-048 -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core"; import { commands } from "$lib/api/bindings";
import type { AudioSettings, VideoSettings, VolumeLevel } from "$lib/api/bindings";
import { import {
getCacheStats, getCacheStats,
setCacheLimit, setCacheLimit,
@@ -12,21 +13,6 @@
type ImageCacheStats, type ImageCacheStats,
} from "$lib/services/imageCache"; } from "$lib/services/imageCache";
type VolumeLevel = "loud" | "normal" | "quiet";
interface AudioSettings {
crossfadeDuration: number;
gaplessPlayback: boolean;
normalizeVolume: boolean;
volumeLevel: VolumeLevel;
}
interface VideoSettings {
autoPlayNextEpisode: boolean;
autoPlayCountdownSeconds: number;
autoPlayMaxEpisodes: number;
}
const episodeLimitOptions = [ const episodeLimitOptions = [
{ value: 0, label: "Unlimited" }, { value: 0, label: "Unlimited" },
{ value: 1, label: "1" }, { value: 1, label: "1" },
@@ -75,8 +61,8 @@
try { try {
loading = true; loading = true;
const [audioResult, videoResult] = await Promise.all([ const [audioResult, videoResult] = await Promise.all([
invoke<AudioSettings>("player_get_audio_settings"), commands.playerGetAudioSettings(),
invoke<VideoSettings>("player_get_video_settings"), commands.playerGetVideoSettings(),
]); ]);
settings = audioResult; settings = audioResult;
videoSettings = videoResult; videoSettings = videoResult;
@@ -142,8 +128,8 @@
saving = true; saving = true;
saveMessage = ""; saveMessage = "";
await Promise.all([ await Promise.all([
invoke("player_set_audio_settings", { settings }), commands.playerSetAudioSettings(settings),
invoke("player_set_video_settings", { settings: videoSettings }), commands.playerSetVideoSettings(videoSettings),
]); ]);
saveMessage = "Settings saved successfully!"; saveMessage = "Settings saved successfully!";
setTimeout(() => { setTimeout(() => {