Compare commits
65
Commits
e3797f32ca
..
v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8f35084b | ||
|
|
4634ed595c | ||
|
|
6836ce79c8 | ||
|
|
2811e1b7ca | ||
|
|
1836615dc0 | ||
|
|
62874564ff | ||
|
|
17a35573a0 | ||
|
|
dcf08f30bc | ||
|
|
674c8e5cd0 | ||
|
|
45aa029916 | ||
|
|
3faa595b76 | ||
|
|
7fb866a583 | ||
|
|
0c3ed74fe1 | ||
|
|
a5b6266a6d | ||
|
|
a816c84f8c | ||
|
|
87762c03b6 | ||
|
|
975936b902 | ||
|
|
5ba9e0e958 | ||
|
|
ccc9dca924 | ||
|
|
60bb6c72d1 | ||
|
|
37cc9b424d | ||
|
|
2daee7ec2d | ||
|
|
01c6423154 | ||
|
|
d01c2aab9f | ||
|
|
14e9d7e03a | ||
|
|
76c78e2edc | ||
|
|
6146d70bc5 | ||
|
|
ea342d76e3 | ||
|
|
7d07b38d50 | ||
|
|
dc1f516da7 | ||
|
|
731966246f | ||
|
|
57ff364f54 | ||
|
|
ada3ed64ab | ||
|
|
55f1b85f12 | ||
|
|
26286ac6e7 | ||
|
|
144abb2f8b | ||
|
|
642ec17069 | ||
|
|
e560258a4b | ||
|
|
d1e5ba4c5d | ||
|
|
8ddad497bf | ||
|
|
46b9d328ab | ||
|
|
dd5d648d21 | ||
|
|
6866f03c55 | ||
|
|
0738ef10ec | ||
|
|
d5bca41c60 | ||
|
|
c959c07ab4 | ||
|
|
d6aa74fc85 | ||
|
|
09780103a7 | ||
|
|
3a9c126dfe | ||
|
|
c5be9eb18c | ||
|
|
e8e37649fa | ||
|
|
07f3bf04ca | ||
|
|
9594e963bc | ||
|
|
179c51a6fe | ||
|
|
59270e8a4f | ||
|
|
b8363aa993 | ||
|
|
410203a5b8 | ||
|
|
d199162dc6 | ||
|
|
27f5c435de | ||
|
|
cbd5435e26 | ||
|
|
a5e065daed | ||
|
|
8f1c4bc9da | ||
|
|
dbcaa1a1a5 | ||
|
|
e664bf4620 | ||
|
|
57f8a54dac |
@@ -14,9 +14,9 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
name: Build APK and Run Tests
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -50,7 +50,9 @@ jobs:
|
|||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
run: bun test
|
run: |
|
||||||
|
bunx svelte-kit sync
|
||||||
|
bun run test
|
||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
run: |
|
run: |
|
||||||
@@ -58,14 +60,69 @@ jobs:
|
|||||||
cargo test
|
cargo test
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build Android APK
|
||||||
|
runs-on: linux/amd64
|
||||||
|
needs: test
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
NDK_VERSION: 27.0.11902837
|
||||||
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ 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
|
||||||
|
run: |
|
||||||
|
bun install
|
||||||
|
|
||||||
- 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
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
echo "" | bunx tauri android init
|
||||||
|
cd ..
|
||||||
|
|
||||||
- name: Build Android APK
|
- name: Build Android APK
|
||||||
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)
|
||||||
|
|||||||
+149
-146
@@ -17,38 +17,41 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
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
|
||||||
@@ -61,47 +64,34 @@ jobs:
|
|||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux
|
name: Build Linux
|
||||||
runs-on: ubuntu-latest
|
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
|
||||||
@@ -133,76 +123,81 @@ jobs:
|
|||||||
|
|
||||||
build-android:
|
build-android:
|
||||||
name: Build Android
|
name: Build Android
|
||||||
runs-on: ubuntu-latest
|
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: Build for Android
|
- name: Set app version from tag
|
||||||
run: bun run tauri android build
|
run: |
|
||||||
env:
|
REF="${GITHUB_REF#refs/tags/v}"
|
||||||
ANDROID_NDK_HOME: ${{ android.ndk-home }}
|
VERSION="${REF#refs/heads/}"
|
||||||
ANDROID_SDK_ROOT: ${{ android.sdk-root }}
|
# On non-tag runs keep whatever is in tauri.conf.json
|
||||||
ANDROID_HOME: ${{ android.sdk-root }}
|
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||||
|
echo "Setting version to $VERSION"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||||
|
fi
|
||||||
|
grep '"version"' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
- name: Prepare Android artifacts
|
- name: Initialize Android project
|
||||||
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
- name: Sync custom Android sources & gradle config
|
||||||
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
|
- name: Write signing keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
|
||||||
|
cat > src-tauri/gen/android/keystore.properties <<EOF
|
||||||
|
storeFile=$RUNNER_TEMP/jellytau-release.jks
|
||||||
|
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build signed Android APK
|
||||||
|
run: bun run tauri android build --apk true --target aarch64
|
||||||
|
|
||||||
|
- name: Collect & verify signed APK
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist/android
|
mkdir -p dist/android
|
||||||
# Copy APK
|
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
|
||||||
if [ -f "src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk" ]; then
|
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
|
||||||
cp src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk dist/android/jellytau-release.apk
|
cp "$APK" dist/android/jellytau-release.apk
|
||||||
fi
|
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
||||||
# Copy AAB (Android App Bundle) if built
|
echo "🔏 Verifying signature with $APKSIGNER"
|
||||||
if [ -f "src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" ]; then
|
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
|
||||||
cp src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab dist/android/jellytau-release.aab
|
|
||||||
fi
|
|
||||||
ls -lah dist/android/
|
ls -lah dist/android/
|
||||||
|
|
||||||
- name: Upload Android build artifact
|
- name: Upload Android build artifact
|
||||||
@@ -214,9 +209,11 @@ jobs:
|
|||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
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
|
||||||
@@ -243,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
|
||||||
@@ -277,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
|
||||||
@@ -292,46 +289,52 @@ 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: Create GitHub Release
|
- name: Publish Gitea release & upload assets
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
with:
|
|
||||||
name: ${{ steps.tag_name.outputs.RELEASE_NAME }}
|
|
||||||
body_path: release_notes.md
|
|
||||||
files: |
|
|
||||||
artifacts/linux/*
|
|
||||||
artifacts/android/*
|
|
||||||
draft: false
|
|
||||||
prerelease: ${{ contains(steps.tag_name.outputs.VERSION, 'rc') || contains(steps.tag_name.outputs.VERSION, 'beta') || contains(steps.tag_name.outputs.VERSION, 'alpha') }}
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
- name: Upload to Gitea Releases
|
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -e
|
||||||
|
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||||
|
API="${GITHUB_SERVER_URL}/api/v1"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||||
|
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
|
||||||
|
|
||||||
echo "📦 Release artifacts prepared for $VERSION"
|
PAYLOAD=$(jq -n \
|
||||||
echo ""
|
--arg tag "$VERSION" \
|
||||||
echo "Linux:"
|
--arg name "JellyTau $VERSION" \
|
||||||
ls -lh artifacts/linux/ || echo "No Linux artifacts"
|
--rawfile body release_notes.md \
|
||||||
echo ""
|
--argjson pre "$PRE" \
|
||||||
echo "Android:"
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
||||||
ls -lh artifacts/android/ || echo "No Android artifacts"
|
|
||||||
echo ""
|
|
||||||
echo "✅ Release $VERSION is ready!"
|
|
||||||
echo "📄 Release notes saved to release_notes.md"
|
|
||||||
|
|
||||||
- name: Publish release notes
|
echo "📦 Creating release $VERSION on $REPO"
|
||||||
run: |
|
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
||||||
echo "## 🎉 Release Published"
|
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||||||
echo ""
|
-H "Authorization: token $TOKEN" \
|
||||||
echo "**Version:** ${{ steps.tag_name.outputs.VERSION }}"
|
-H "Content-Type: application/json" \
|
||||||
echo "**Tag:** ${{ github.ref }}"
|
-d "$PAYLOAD")
|
||||||
echo ""
|
if [ "$HTTP" = "201" ]; then
|
||||||
echo "Artifacts:"
|
RELEASE_ID=$(jq -r '.id' resp.json)
|
||||||
echo "- Linux artifacts in: artifacts/linux/"
|
elif [ "$HTTP" = "409" ]; then
|
||||||
echo "- Android artifacts in: artifacts/android/"
|
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
||||||
echo ""
|
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
||||||
echo "Visit the Release page to download files."
|
-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"
|
||||||
|
|
||||||
|
for f in artifacts/android/* artifacts/linux/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
echo "⬆️ Uploading $(basename "$f")"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" >/dev/null
|
||||||
|
done
|
||||||
|
echo "✅ Release $VERSION published with assets"
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-traces:
|
validate-traces:
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux/amd64
|
||||||
name: Check Requirement Traces
|
name: Check Requirement Traces
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -93,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 ""
|
||||||
@@ -127,9 +137,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo ""
|
echo ""
|
||||||
echo "📊 Full Report Generated"
|
echo "📊 Full Report Generated"
|
||||||
echo "📁 Location: docs/TRACEABILITY.md"
|
echo "📁 Location: docs/traceability.md"
|
||||||
echo ""
|
echo ""
|
||||||
head -50 docs/TRACEABILITY.md || true
|
head -50 docs/traceability.md || true
|
||||||
|
|
||||||
- name: Save artifacts
|
- name: Save artifacts
|
||||||
if: always()
|
if: always()
|
||||||
@@ -138,5 +148,5 @@ jobs:
|
|||||||
name: traceability-reports
|
name: traceability-reports
|
||||||
path: |
|
path: |
|
||||||
traces-report.json
|
traces-report.json
|
||||||
docs/TRACEABILITY.md
|
docs/traceability.md
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
traceability:
|
traceability:
|
||||||
name: Validate Requirement Traces
|
name: Validate Requirement Traces
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -133,7 +135,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: traceability-report
|
name: traceability-report
|
||||||
path: docs/TRACEABILITY.md
|
path: docs/traceability.md
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Comment PR with coverage report
|
- name: Comment PR with coverage report
|
||||||
|
|||||||
+11
@@ -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
|
||||||
@@ -47,3 +52,9 @@ vite.config.ts.timestamp-*
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Android signing keystore (NEVER commit)
|
||||||
|
android-keystore/
|
||||||
|
|
||||||
|
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||||
|
src-tauri/.cargo/config.toml
|
||||||
|
|||||||
+11
@@ -27,6 +27,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libssl-dev \
|
libssl-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
llvm-dev \
|
llvm-dev \
|
||||||
|
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
# mpv player library (linked via libmpv-sys)
|
||||||
|
libmpv-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
# Install Node.js 20.x from NodeSource
|
||||||
@@ -84,6 +94,7 @@ RUN cd src-tauri && cargo fetch && cd ..
|
|||||||
FROM builder AS test
|
FROM builder AS test
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN echo "Running tests..." && \
|
RUN echo "Running tests..." && \
|
||||||
|
bunx svelte-kit sync && \
|
||||||
bun run test && \
|
bun run test && \
|
||||||
cd src-tauri && cargo test && cd .. && \
|
cd src-tauri && cargo test && cd .. && \
|
||||||
echo "All tests passed!"
|
echo "All tests passed!"
|
||||||
|
|||||||
+19
-3
@@ -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
|
||||||
@@ -20,11 +21,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
unzip \
|
unzip \
|
||||||
|
jq \
|
||||||
openjdk-17-jdk-headless \
|
openjdk-17-jdk-headless \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
llvm-dev \
|
llvm-dev \
|
||||||
|
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
# mpv player library (linked via libmpv-sys)
|
||||||
|
libmpv-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
# Install Node.js 20.x from NodeSource
|
||||||
@@ -57,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
# Code Review Fixes Summary
|
|
||||||
|
|
||||||
This document summarizes all the critical bugs and architectural issues that have been fixed in the JellyTau project.
|
|
||||||
|
|
||||||
## Fixed Issues
|
|
||||||
|
|
||||||
### 🔴 CRITICAL
|
|
||||||
|
|
||||||
#### 1. **Fixed nextEpisode Event Handlers - Undefined Method Calls**
|
|
||||||
- **File:** `src/lib/services/playerEvents.ts`
|
|
||||||
- **Issue:** Lines 272 and 280 were calling `nextEpisode.showPopup()` and `nextEpisode.updateCountdown()` on an undefined variable.
|
|
||||||
- **Root Cause:** The import was aliased as `showNextEpisodePopup` but the code tried to use an undefined `nextEpisode` variable.
|
|
||||||
- **Fix:** Changed import to import the `nextEpisode` store directly, renamed parameters to avoid shadowing.
|
|
||||||
- **Impact:** Prevents runtime crashes when next episode popup events are emitted from the Rust backend.
|
|
||||||
|
|
||||||
#### 2. **Replaced Queue Polling with Event-Based Updates**
|
|
||||||
- **File:** `src/routes/+layout.svelte`, `src/lib/services/playerEvents.ts`
|
|
||||||
- **Issue:** Frontend was polling backend every 1 second (`setInterval(updateQueueStatus, 1000)`) for queue status.
|
|
||||||
- **Root Cause:** Inefficient polling approach creates unnecessary backend load and battery drain.
|
|
||||||
- **Fix:**
|
|
||||||
- Removed continuous polling
|
|
||||||
- Added `updateQueueStatus()` calls on `state_changed` events
|
|
||||||
- Listeners now trigger updates when playback state changes instead
|
|
||||||
- **Impact:** Reduces backend load, improves battery life, more reactive to state changes.
|
|
||||||
|
|
||||||
### 🟠 HIGH PRIORITY
|
|
||||||
|
|
||||||
#### 3. **Moved Device ID to Secure Storage**
|
|
||||||
- **Files:** `src/lib/services/deviceId.ts` (new), `src/lib/stores/auth.ts`
|
|
||||||
- **Issue:** Device ID was stored in browser localStorage, accessible to XSS attacks.
|
|
||||||
- **Fix:**
|
|
||||||
- Created `deviceId.ts` service that uses Tauri's secure storage commands
|
|
||||||
- Replaced all `localStorage.getItem("jellytau_device_id")` calls with `getDeviceId()`
|
|
||||||
- Added caching for performance
|
|
||||||
- Implemented fallback to in-memory ID if secure storage unavailable
|
|
||||||
- **Impact:** Enhanced security posture against XSS attacks.
|
|
||||||
|
|
||||||
#### 4. **Fixed Event Listener Memory Leaks**
|
|
||||||
- **File:** `src/lib/stores/auth.ts`, `src/routes/+layout.svelte`
|
|
||||||
- **Issue:** Event listeners (`listen()` calls) were registered at module load with no cleanup.
|
|
||||||
- **Fix:**
|
|
||||||
- Moved listener registration to `initializeEventListeners()` function
|
|
||||||
- Stored unlisten functions and call them in cleanup
|
|
||||||
- Added `cleanupEventListeners()` to auth store export
|
|
||||||
- Called cleanup in `onDestroy()` of layout component
|
|
||||||
- **Impact:** Prevents memory leaks from duplicate listeners if store/routes are reloaded.
|
|
||||||
|
|
||||||
#### 5. **Replaced Browser Alerts with Toast Notifications**
|
|
||||||
- **File:** `src/lib/components/library/TrackList.svelte`
|
|
||||||
- **Issue:** Using native `alert()` for errors, which blocks execution and provides poor UX.
|
|
||||||
- **Fix:**
|
|
||||||
- Imported `toast` store
|
|
||||||
- Replaced `alert()` with `toast.error()` call with 5-second timeout
|
|
||||||
- Improved error message formatting
|
|
||||||
- **Impact:** Non-blocking error notifications with better UX.
|
|
||||||
|
|
||||||
#### 6. **Removed Silent Error Handlers**
|
|
||||||
- **Files:** `src/lib/services/playbackReporting.ts`, `src/lib/services/imageCache.ts`, `src/lib/services/playerEvents.ts`
|
|
||||||
- **Issue:** Multiple `.catch(() => {})` handlers silently swallowed errors.
|
|
||||||
- **Fix:**
|
|
||||||
- Added proper error logging with `console.debug()` and `console.error()`
|
|
||||||
- Added comments explaining why failures are non-critical
|
|
||||||
- Made error handling explicit and debuggable
|
|
||||||
- **Impact:** Improved debugging and visibility into failures.
|
|
||||||
|
|
||||||
### 🟡 MEDIUM PRIORITY
|
|
||||||
|
|
||||||
#### 7. **Fixed Race Condition in Downloads Store**
|
|
||||||
- **File:** `src/lib/stores/downloads.ts`
|
|
||||||
- **Issue:** Concurrent calls to `refreshDownloads()` could interleave state updates, corrupting state.
|
|
||||||
- **Fix:**
|
|
||||||
- Added `refreshInProgress` flag to prevent concurrent calls
|
|
||||||
- Implemented queuing mechanism for pending refresh requests
|
|
||||||
- Requests are processed sequentially
|
|
||||||
- **Impact:** Prevents race condition-induced data corruption in download state.
|
|
||||||
|
|
||||||
#### 8. **Centralized Duration Formatting Utility**
|
|
||||||
- **File:** `src/lib/utils/duration.ts` (new), `src/lib/components/library/TrackList.svelte`, `src/lib/components/library/LibraryListView.svelte`
|
|
||||||
- **Issue:** Duration formatting logic duplicated across components with magic number `10000000`.
|
|
||||||
- **Fix:**
|
|
||||||
- Created `duration.ts` utility with `formatDuration()` and `formatSecondsDuration()` functions
|
|
||||||
- Added support for both mm:ss and hh:mm:ss formats
|
|
||||||
- Replaced all component-level functions with imports
|
|
||||||
- Documented the Jellyfin tick-to-second conversion (10M ticks = 1 second)
|
|
||||||
- **Impact:** Single source of truth for duration formatting, easier maintenance.
|
|
||||||
|
|
||||||
#### 9. **Added Input Validation to Image URLs**
|
|
||||||
- **File:** `src/lib/utils/validation.ts` (new), `src/lib/api/repository-client.ts`
|
|
||||||
- **Issue:** Item IDs and image types not validated, vulnerable to path traversal attacks.
|
|
||||||
- **Fix:**
|
|
||||||
- Created `validation.ts` with comprehensive input validators:
|
|
||||||
- `validateItemId()` - rejects invalid characters and excessive length
|
|
||||||
- `validateImageType()` - whitelist of allowed types
|
|
||||||
- `validateMediaSourceId()` - similar to item ID validation
|
|
||||||
- `validateNumericParam()` - bounds checking for widths, heights, quality, etc.
|
|
||||||
- `validateQueryParamValue()` - safe query parameter validation
|
|
||||||
- Applied validation to all URL construction methods in repository-client.ts
|
|
||||||
- Added explicit bounds checking for numeric parameters
|
|
||||||
- **Impact:** Prevents injection attacks and path traversal vulnerabilities.
|
|
||||||
|
|
||||||
#### 10. **Improved Error Handling in Layout Component**
|
|
||||||
- **File:** `src/routes/+layout.svelte`
|
|
||||||
- **Issue:** Silent `.catch()` handler in connectivity monitoring could mask failures.
|
|
||||||
- **Fix:**
|
|
||||||
- Changed from `.catch(() => {})` to proper error handling with logging
|
|
||||||
- Added debug messages explaining failure modes
|
|
||||||
- Implemented async/await with proper error chaining
|
|
||||||
- **Impact:** Better observability of connectivity issues.
|
|
||||||
|
|
||||||
## Unit Tests Added
|
|
||||||
|
|
||||||
Comprehensive test suites have been added for critical utilities and services:
|
|
||||||
|
|
||||||
### Test Files Created
|
|
||||||
1. **`src/lib/utils/duration.test.ts`**
|
|
||||||
- Tests for `formatDuration()` and `formatSecondsDuration()`
|
|
||||||
- Covers Jellyfin tick conversion, various time formats, edge cases
|
|
||||||
- 10+ test cases
|
|
||||||
|
|
||||||
2. **`src/lib/utils/validation.test.ts`**
|
|
||||||
- Tests for all validation functions
|
|
||||||
- Covers valid inputs, invalid characters, bounds checking
|
|
||||||
- Tests for injection prevention
|
|
||||||
- 25+ test cases
|
|
||||||
|
|
||||||
3. **`src/lib/services/deviceId.test.ts`**
|
|
||||||
- Tests for device ID generation and caching
|
|
||||||
- Tests for secure storage fallback
|
|
||||||
- Tests for cache clearing on logout
|
|
||||||
- 8+ test cases
|
|
||||||
|
|
||||||
4. **`src/lib/services/playerEvents.test.ts`**
|
|
||||||
- Tests for event listener initialization
|
|
||||||
- Tests for cleanup and memory leak prevention
|
|
||||||
- Tests for error handling
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
```bash
|
|
||||||
npm run test
|
|
||||||
npm run test:ui # Interactive UI
|
|
||||||
npm run test:coverage # With coverage report
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Improvements
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
- ✅ Duration formatting moved to dedicated utility
|
|
||||||
- ✅ Device ID management centralized in service
|
|
||||||
- ✅ Input validation extracted to validation utility
|
|
||||||
- ✅ Event listener lifecycle properly managed
|
|
||||||
|
|
||||||
### Security Enhancements
|
|
||||||
- ✅ Device ID moved from localStorage to secure storage
|
|
||||||
- ✅ Input validation on all user-influenced URL parameters
|
|
||||||
- ✅ Path traversal attack prevention via whitelist validation
|
|
||||||
- ✅ Numeric parameter bounds checking
|
|
||||||
|
|
||||||
### Performance Improvements
|
|
||||||
- ✅ Eliminated 1-second polling (1000 calls/hour reduced to event-driven)
|
|
||||||
- ✅ Prevented race conditions in state management
|
|
||||||
- ✅ Added request queuing to prevent concurrent backend thrashing
|
|
||||||
|
|
||||||
### Reliability Improvements
|
|
||||||
- ✅ Fixed critical runtime errors (nextEpisode handlers)
|
|
||||||
- ✅ Proper memory cleanup prevents leaks
|
|
||||||
- ✅ Better error handling with visibility
|
|
||||||
- ✅ Comprehensive test coverage for utilities
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### Core Fixes
|
|
||||||
- `src/lib/services/playerEvents.ts` - Fixed event handlers, replaced polling
|
|
||||||
- `src/routes/+layout.svelte` - Removed polling, proper cleanup
|
|
||||||
- `src/lib/stores/auth.ts` - Device ID management, event listener cleanup
|
|
||||||
- `src/lib/stores/downloads.ts` - Race condition prevention
|
|
||||||
- `src/lib/api/repository-client.ts` - Input validation on URLs
|
|
||||||
- `src/lib/components/library/TrackList.svelte` - Toast notifications, centralized duration
|
|
||||||
- `src/lib/components/library/LibraryListView.svelte` - Centralized duration formatting
|
|
||||||
- `src/lib/services/playbackReporting.ts` - Removed silent error handlers
|
|
||||||
- `src/lib/services/imageCache.ts` - Improved error logging
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
- `src/lib/services/deviceId.ts` - Device ID service (new)
|
|
||||||
- `src/lib/utils/duration.ts` - Duration formatting utility (new)
|
|
||||||
- `src/lib/utils/validation.ts` - Input validation utility (new)
|
|
||||||
- `src/lib/utils/duration.test.ts` - Duration tests (new)
|
|
||||||
- `src/lib/utils/validation.test.ts` - Validation tests (new)
|
|
||||||
- `src/lib/services/deviceId.test.ts` - Device ID tests (new)
|
|
||||||
- `src/lib/services/playerEvents.test.ts` - Player events tests (new)
|
|
||||||
|
|
||||||
## Testing Notes
|
|
||||||
|
|
||||||
The codebase is now equipped with:
|
|
||||||
- ✅ Unit tests for duration formatting
|
|
||||||
- ✅ Unit tests for input validation
|
|
||||||
- ✅ Unit tests for device ID service
|
|
||||||
- ✅ Unit tests for player events service
|
|
||||||
- ✅ Proper mocking of Tauri APIs
|
|
||||||
- ✅ Vitest configuration ready to use
|
|
||||||
|
|
||||||
Run tests with: `npm run test`
|
|
||||||
|
|
||||||
## Recommendations for Future Work
|
|
||||||
|
|
||||||
1. **Move sorting/filtering to backend** - Currently done in frontend, should delegate to server
|
|
||||||
2. **Move API URL construction to backend** - Currently in frontend, security risk
|
|
||||||
3. **Remove more hardcoded configuration values** - Audit for magic numbers throughout codebase
|
|
||||||
4. **Add CSP headers validation** - Ensure content security policies are properly enforced
|
|
||||||
5. **Implement proper rate limiting** - Add debouncing to frequently called operations
|
|
||||||
6. **Expand test coverage** - Add tests for stores, components, and more services
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
All changes are backward compatible:
|
|
||||||
- Device ID service falls back to in-memory ID if secure storage fails
|
|
||||||
- Duration formatting maintains same output format
|
|
||||||
- Validation is defensive and allows valid inputs
|
|
||||||
- Event listeners are properly cleaned up to prevent leaks
|
|
||||||
|
|
||||||
## Performance Impact
|
|
||||||
|
|
||||||
- **Positive:** 90% reduction in backend polling calls (1000/hour → event-driven)
|
|
||||||
- **Positive:** Eliminated race conditions that could cause state corruption
|
|
||||||
- **Positive:** Reduced memory footprint via proper cleanup
|
|
||||||
- **Neutral:** Input validation adds minimal overhead (happens before URL construction)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Total Issues Fixed:** 10 critical/high-priority items
|
|
||||||
**Lines of Code Added:** ~800 (utilities, tests, validation)
|
|
||||||
**Test Coverage:** 45+ test cases across 4 test files
|
|
||||||
**Estimated Impact:** High reliability and security improvements
|
|
||||||
@@ -2,323 +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 | In Progress |
|
|
||||||
| 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 | Planned |
|
|
||||||
| 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 | Planned |
|
|
||||||
| UR-021 | Select audio track for video content | High | Planned |
|
|
||||||
| 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 | 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 playback | 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 | Planned |
|
|
||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
|
||||||
| IR-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 | Planned |
|
|
||||||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
|
|
||||||
| 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 | Planned |
|
|
||||||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
|
|
||||||
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
|
|
||||||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
|
|
||||||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| 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 | Planned |
|
|
||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
|
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
|
|
||||||
|
|
||||||
### 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 | Planned |
|
|
||||||
| 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 | Planned |
|
|
||||||
| 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 | Planned |
|
|
||||||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
|
|
||||||
| 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 countdown and auto-stop | Player | 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 | Player | UR-023 | Done |
|
|
||||||
| DR-048 | Video settings (auto-play toggle, countdown duration) | 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 | - | 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 |
|
|
||||||
| UR-024 | IR-010 | DR-027 |
|
|
||||||
| UR-025 | IR-015 | DR-028 |
|
|
||||||
| UR-026 | - | DR-029, DR-048 |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### 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
|
||||||
@@ -328,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
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -12,7 +12,7 @@ services:
|
|||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
- RUST_BACKTRACE=1
|
- RUST_BACKTRACE=1
|
||||||
command: bash -c "bun test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
|
command: bash -c "bun run test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
|
||||||
|
|
||||||
# Android build service - builds APK after tests pass
|
# Android build service - builds APK after tests pass
|
||||||
android-build:
|
android-build:
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
# Rust Backend Architecture
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/`
|
||||||
|
|
||||||
|
## Media Session State Machine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/session.rs`
|
||||||
|
|
||||||
|
The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.
|
||||||
|
|
||||||
|
**Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
|
||||||
|
Idle --> AudioActive : play_queue(audio)
|
||||||
|
Idle --> MovieActive : play_item(movie)
|
||||||
|
Idle --> TvShowActive : play_item(episode)
|
||||||
|
|
||||||
|
state "Audio Session" as AudioSession {
|
||||||
|
[*] --> AudioActive
|
||||||
|
AudioActive --> AudioInactive : playback_ended
|
||||||
|
AudioInactive --> AudioActive : resume/play
|
||||||
|
AudioActive --> AudioActive : next/previous
|
||||||
|
}
|
||||||
|
|
||||||
|
state "Movie Session" as MovieSession {
|
||||||
|
[*] --> MovieActive
|
||||||
|
MovieActive --> MovieInactive : playback_ended
|
||||||
|
MovieInactive --> MovieActive : resume
|
||||||
|
}
|
||||||
|
|
||||||
|
state "TV Show Session" as TvShowSession {
|
||||||
|
[*] --> TvShowActive
|
||||||
|
TvShowActive --> TvShowInactive : playback_ended
|
||||||
|
TvShowInactive --> TvShowActive : next_episode/resume
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioSession --> Idle : dismiss/clear_queue
|
||||||
|
AudioSession --> MovieSession : play_item(movie)
|
||||||
|
AudioSession --> TvShowSession : play_item(episode)
|
||||||
|
|
||||||
|
MovieSession --> Idle : dismiss/playback_complete
|
||||||
|
MovieSession --> AudioSession : play_queue(audio)
|
||||||
|
|
||||||
|
TvShowSession --> Idle : dismiss/series_complete
|
||||||
|
TvShowSession --> AudioSession : play_queue(audio)
|
||||||
|
|
||||||
|
note right of Idle
|
||||||
|
No active media session
|
||||||
|
Queue may exist but not playing
|
||||||
|
No miniplayer/video player shown
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of AudioSession
|
||||||
|
SHOW: Miniplayer (always visible)
|
||||||
|
- Active: Play/pause/skip controls enabled
|
||||||
|
- Inactive: Play button to resume queue
|
||||||
|
Persists until explicit dismiss
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of MovieSession
|
||||||
|
SHOW: Full video player
|
||||||
|
- Active: Video playing/paused
|
||||||
|
- Inactive: Resume dialog
|
||||||
|
Auto-dismiss when playback ends
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of TvShowSession
|
||||||
|
SHOW: Full video player + Next Episode UI
|
||||||
|
- Active: Video playing/paused
|
||||||
|
- Inactive: Next episode prompt
|
||||||
|
Auto-dismiss when series ends
|
||||||
|
end note
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session State Enum:**
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum MediaSessionType {
|
||||||
|
/// No active session - browsing library
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// Audio playback session (music, audiobooks, podcasts)
|
||||||
|
/// Persists until explicitly dismissed
|
||||||
|
Audio {
|
||||||
|
/// Last/current track being played
|
||||||
|
last_item: Option<MediaItem>,
|
||||||
|
/// True = playing/paused, False = stopped/ended
|
||||||
|
is_active: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Movie playback (single video, auto-dismiss on end)
|
||||||
|
Movie {
|
||||||
|
item: MediaItem,
|
||||||
|
is_active: bool, // true = playing/paused, false = ended
|
||||||
|
},
|
||||||
|
|
||||||
|
/// TV show playback (supports next episode auto-advance)
|
||||||
|
TvShow {
|
||||||
|
item: MediaItem,
|
||||||
|
series_id: String,
|
||||||
|
is_active: bool, // true = playing/paused, false = ended
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Transitions & Rules:**
|
||||||
|
|
||||||
|
| From State | Event | To State | UI Behavior | Notes |
|
||||||
|
|------------|-------|----------|-------------|-------|
|
||||||
|
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
|
||||||
|
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
|
||||||
|
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
|
||||||
|
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
|
||||||
|
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
|
||||||
|
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
|
||||||
|
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
|
||||||
|
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
|
||||||
|
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
|
||||||
|
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
|
||||||
|
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
|
||||||
|
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
|
||||||
|
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
|
||||||
|
3. **Single Active Session**: Playing new content type replaces current session
|
||||||
|
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
|
||||||
|
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
|
||||||
|
|
||||||
|
**Edge Cases Handled:**
|
||||||
|
|
||||||
|
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
|
||||||
|
- User wants to dismiss: Close button clears session -> Idle
|
||||||
|
- Switch content types: New session replaces old (audio -> movie)
|
||||||
|
- Paused for extended time: Session persists indefinitely
|
||||||
|
- Playback errors: Session stays inactive, allows retry
|
||||||
|
- Queue operations while idle: Queue exists but no session created until play
|
||||||
|
|
||||||
|
## Player State Machine (Low-Level Playback)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/state.rs`
|
||||||
|
|
||||||
|
The player uses a deterministic state machine with 6 states (operates within a media session):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
Idle --> Loading : Load
|
||||||
|
Loading --> Playing : MediaLoaded
|
||||||
|
Playing --> Paused : Pause
|
||||||
|
Paused --> Playing : Play
|
||||||
|
Paused --> Seeking : Seek
|
||||||
|
Seeking --> Playing : PositionUpdate
|
||||||
|
Playing --> Idle : Stop
|
||||||
|
Paused --> Idle : Stop
|
||||||
|
Idle --> Error : Error
|
||||||
|
Loading --> Error : Error
|
||||||
|
Playing --> Error : Error
|
||||||
|
Paused --> Error : Error
|
||||||
|
Seeking --> Error : Error
|
||||||
|
|
||||||
|
state Playing {
|
||||||
|
[*] : position, duration
|
||||||
|
}
|
||||||
|
state Paused {
|
||||||
|
[*] : position, duration
|
||||||
|
}
|
||||||
|
state Seeking {
|
||||||
|
[*] : target
|
||||||
|
}
|
||||||
|
state Error {
|
||||||
|
[*] : error message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerState {
|
||||||
|
Idle,
|
||||||
|
Loading { media: MediaItem },
|
||||||
|
Playing { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Paused { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Seeking { media: MediaItem, target: f64 },
|
||||||
|
Error { media: Option<MediaItem>, error: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerEvent {
|
||||||
|
Load(MediaItem),
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
Seek(f64),
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
MediaLoaded(f64), // duration
|
||||||
|
PositionUpdate(f64), // position
|
||||||
|
PlaybackEnded,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback Mode State Machine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
||||||
|
|
||||||
|
The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
|
||||||
|
Idle --> Local : play_queue()
|
||||||
|
Idle --> Remote : transfer_to_remote(session_id)
|
||||||
|
|
||||||
|
Local --> Remote : transfer_to_remote(session_id)
|
||||||
|
Local --> Idle : stop()
|
||||||
|
|
||||||
|
Remote --> Local : transfer_to_local()
|
||||||
|
Remote --> Idle : session_disconnected()
|
||||||
|
Remote --> Idle : stop()
|
||||||
|
|
||||||
|
state Local {
|
||||||
|
[*] : Playing on device
|
||||||
|
[*] : ExoPlayer active
|
||||||
|
[*] : Volume buttons -> device
|
||||||
|
}
|
||||||
|
|
||||||
|
state Remote {
|
||||||
|
[*] : Controlling session
|
||||||
|
[*] : session_id
|
||||||
|
[*] : Volume buttons -> remote
|
||||||
|
[*] : Android: VolumeProvider active
|
||||||
|
}
|
||||||
|
|
||||||
|
state Idle {
|
||||||
|
[*] : No active playback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlaybackMode {
|
||||||
|
Local, // Playing on local device
|
||||||
|
Remote { session_id: String }, // Controlling remote Jellyfin session
|
||||||
|
Idle, // No active playback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Transitions:**
|
||||||
|
|
||||||
|
| From | Event | To | Side Effects |
|
||||||
|
|------|-------|-----|----|
|
||||||
|
| Idle | `play_queue()` | Local | Start local playback |
|
||||||
|
| Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session |
|
||||||
|
| Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) |
|
||||||
|
| Local | `stop()` | Idle | Stop local playback |
|
||||||
|
| Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume |
|
||||||
|
| Remote | `stop()` | Idle | Stop remote playback, disable remote volume |
|
||||||
|
| Remote | `session_disconnected()` | Idle | Session lost, disable remote volume |
|
||||||
|
|
||||||
|
**Integration with Player State Machine:**
|
||||||
|
|
||||||
|
- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
|
||||||
|
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
|
||||||
|
- When `PlaybackMode = Idle`: Player state is Idle
|
||||||
|
|
||||||
|
**Android Volume Control Integration:**
|
||||||
|
|
||||||
|
When transitioning to `Remote` mode on Android:
|
||||||
|
1. Call `enable_remote_volume(initial_volume)`
|
||||||
|
2. VolumeProviderCompat intercepts hardware volume buttons
|
||||||
|
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
|
||||||
|
4. Volume commands routed to remote session via Jellyfin API
|
||||||
|
|
||||||
|
When transitioning away from `Remote` mode:
|
||||||
|
1. Call `disable_remote_volume()`
|
||||||
|
2. Volume buttons return to controlling device volume
|
||||||
|
3. PlaybackStateCompat set to STATE_NONE
|
||||||
|
4. VolumeProviderCompat is cleared
|
||||||
|
|
||||||
|
## Media Item & Source
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/media.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub artwork_url: Option<String>,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub source: MediaSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MediaType {
|
||||||
|
Audio,
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MediaSource {
|
||||||
|
Remote {
|
||||||
|
stream_url: String,
|
||||||
|
jellyfin_item_id: String,
|
||||||
|
},
|
||||||
|
Local {
|
||||||
|
file_path: PathBuf,
|
||||||
|
jellyfin_item_id: Option<String>,
|
||||||
|
},
|
||||||
|
DirectUrl {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `MediaSource` enum enables:
|
||||||
|
- **Remote**: Streaming from Jellyfin server
|
||||||
|
- **Local**: Downloaded/cached files (future offline support)
|
||||||
|
- **DirectUrl**: Direct URLs (channel plugins, external sources)
|
||||||
|
|
||||||
|
## Queue Manager
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/queue.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct QueueManager {
|
||||||
|
items: Vec<MediaItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
shuffle: bool,
|
||||||
|
repeat: RepeatMode,
|
||||||
|
shuffle_order: Vec<usize>, // Fisher-Yates permutation
|
||||||
|
history: Vec<usize>, // For back navigation in shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum RepeatMode {
|
||||||
|
Off,
|
||||||
|
All,
|
||||||
|
One,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queue Navigation Logic:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
QM[QueueManager]
|
||||||
|
QM --> Shuffle
|
||||||
|
QM --> Repeat
|
||||||
|
QM --> History
|
||||||
|
|
||||||
|
subgraph Shuffle["Shuffle Mode"]
|
||||||
|
ShuffleOff["OFF<br/>next() returns index + 1"]
|
||||||
|
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Repeat["Repeat Mode"]
|
||||||
|
RepeatOff["OFF<br/>next() at end: -> None"]
|
||||||
|
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
|
||||||
|
RepeatOne["ONE<br/>next() returns same item"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph History["History"]
|
||||||
|
HistoryDesc["Used for previous()<br/>in shuffle mode"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Favorites System
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
- Service: `src/lib/services/favorites.ts`
|
||||||
|
- Component: `src/lib/components/FavoriteButton.svelte`
|
||||||
|
- Backend: `src-tauri/src/commands/storage.rs`
|
||||||
|
|
||||||
|
The favorites system implements optimistic updates with server synchronization:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
|
||||||
|
Service -->|1. Optimistic| LocalDB[(SQLite user_data)]
|
||||||
|
Service -->|2. Sync| JellyfinAPI[Jellyfin API]
|
||||||
|
Service -->|3. Mark Synced| LocalDB
|
||||||
|
|
||||||
|
JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
||||||
|
JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
||||||
|
|
||||||
|
LocalDB -->|is_favorite<br/>pending_sync| UserData[user_data table]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
|
||||||
|
2. `toggleFavorite()` service function handles the logic:
|
||||||
|
- Updates local SQLite database immediately (optimistic update)
|
||||||
|
- Attempts to sync with Jellyfin server
|
||||||
|
- Marks as synced if successful, otherwise leaves `pending_sync = 1`
|
||||||
|
3. UI reflects the change immediately without waiting for server response
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- **FavoriteButton.svelte**: Reusable heart button component
|
||||||
|
- Configurable size (sm/md/lg)
|
||||||
|
- Red when favorited, gray when not
|
||||||
|
- Loading state during toggle
|
||||||
|
- Bindable `isFavorite` prop for two-way binding
|
||||||
|
|
||||||
|
- **Integration Points**:
|
||||||
|
- MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
|
||||||
|
- Full AudioPlayer: Shows favorite button (planned)
|
||||||
|
- Album/Artist detail pages: Shows favorite button (planned)
|
||||||
|
|
||||||
|
**Database Schema**:
|
||||||
|
- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1)
|
||||||
|
- `user_data.pending_sync`: Indicates if local changes need syncing
|
||||||
|
|
||||||
|
**Tauri Commands**:
|
||||||
|
- `storage_toggle_favorite`: Updates favorite status in local database
|
||||||
|
- `storage_mark_synced`: Clears pending_sync flag after successful sync
|
||||||
|
|
||||||
|
**API Methods**:
|
||||||
|
- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin
|
||||||
|
- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin
|
||||||
|
|
||||||
|
## Player Backend Trait
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/backend.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait PlayerBackend: Send + Sync {
|
||||||
|
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
||||||
|
fn play(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn stop(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
||||||
|
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||||
|
fn position(&self) -> f64;
|
||||||
|
fn duration(&self) -> Option<f64>;
|
||||||
|
fn state(&self) -> PlayerState;
|
||||||
|
fn is_loaded(&self) -> bool;
|
||||||
|
fn volume(&self) -> f32;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementations:**
|
||||||
|
- `NullBackend` - Mock backend for testing
|
||||||
|
- `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md))
|
||||||
|
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md))
|
||||||
|
|
||||||
|
## Player Controller
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mod.rs`
|
||||||
|
|
||||||
|
The `PlayerController` orchestrates playback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PlayerController {
|
||||||
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||||
|
queue: Arc<Mutex<QueueManager>>,
|
||||||
|
muted: bool,
|
||||||
|
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
||||||
|
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
||||||
|
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
||||||
|
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
||||||
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||||
|
// ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `play_item(item)`: Load and play single item (resets autoplay counter)
|
||||||
|
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
|
||||||
|
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
|
||||||
|
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
||||||
|
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
|
||||||
|
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
|
||||||
|
|
||||||
|
## Playlist System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`
|
||||||
|
|
||||||
|
**TRACES**: UR-014 | JA-019 | JA-020
|
||||||
|
|
||||||
|
The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// A media item within a playlist, with its distinct playlist entry ID
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistEntry {
|
||||||
|
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
|
||||||
|
pub playlist_item_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub item: MediaItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of creating a new playlist
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistCreatedResult {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.
|
||||||
|
|
||||||
|
**MediaRepository Trait Methods:**
|
||||||
|
```rust
|
||||||
|
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
||||||
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||||
|
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
|
||||||
|
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
|
||||||
|
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache Strategy:**
|
||||||
|
- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository
|
||||||
|
- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
|
||||||
|
- Background cache update after server fetch via `save_playlist_items_to_cache()`
|
||||||
|
|
||||||
|
**Playlist Tauri Commands:**
|
||||||
|
|
||||||
|
| Command | Parameters | Returns |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` |
|
||||||
|
| `playlist_delete` | `handle, playlist_id` | `()` |
|
||||||
|
| `playlist_rename` | `handle, playlist_id, name` | `()` |
|
||||||
|
| `playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>` |
|
||||||
|
| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` |
|
||||||
|
| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` |
|
||||||
|
| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` |
|
||||||
|
|
||||||
|
## Tauri Commands (Player)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/player.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Returns |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| `player_play_item` | `PlayItemRequest` | `PlayerStatus` |
|
||||||
|
| `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` |
|
||||||
|
| `player_play` | - | `PlayerStatus` |
|
||||||
|
| `player_pause` | - | `PlayerStatus` |
|
||||||
|
| `player_toggle` | - | `PlayerStatus` |
|
||||||
|
| `player_stop` | - | `PlayerStatus` |
|
||||||
|
| `player_next` | - | `PlayerStatus` |
|
||||||
|
| `player_previous` | - | `PlayerStatus` |
|
||||||
|
| `player_seek` | `position: f64` | `PlayerStatus` |
|
||||||
|
| `player_set_volume` | `volume: f32` | `PlayerStatus` |
|
||||||
|
| `player_toggle_shuffle` | - | `QueueStatus` |
|
||||||
|
| `player_cycle_repeat` | - | `QueueStatus` |
|
||||||
|
| `player_get_status` | - | `PlayerStatus` |
|
||||||
|
| `player_get_queue` | - | `QueueStatus` |
|
||||||
|
| `player_get_session` | - | `MediaSessionType` |
|
||||||
|
| `player_dismiss_session` | - | `()` |
|
||||||
|
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
|
||||||
|
| `player_cancel_sleep_timer` | - | `()` |
|
||||||
|
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
|
||||||
|
| `player_get_video_settings` | - | `VideoSettings` |
|
||||||
|
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
|
||||||
|
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
|
||||||
|
| `player_on_playback_ended` | - | `()` |
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
# Svelte Frontend Architecture
|
||||||
|
|
||||||
|
## Store Structure
|
||||||
|
|
||||||
|
**Location**: `src/lib/stores/`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Stores
|
||||||
|
subgraph auth["auth.ts"]
|
||||||
|
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
|
||||||
|
end
|
||||||
|
subgraph playerStore["player.ts"]
|
||||||
|
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
|
||||||
|
end
|
||||||
|
subgraph queueStore["queue.ts"]
|
||||||
|
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
|
||||||
|
end
|
||||||
|
subgraph libraryStore["library.ts"]
|
||||||
|
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
|
||||||
|
end
|
||||||
|
subgraph Derived["Derived Stores"]
|
||||||
|
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Music Library Architecture
|
||||||
|
|
||||||
|
**Category-Based Navigation:**
|
||||||
|
|
||||||
|
JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.
|
||||||
|
|
||||||
|
**Route Structure:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Music["/library/music<br/>(Landing page with category cards)"]
|
||||||
|
Tracks["Tracks<br/>(List view only)"]
|
||||||
|
Artists["Artists<br/>(Grid view)"]
|
||||||
|
Albums["Albums<br/>(Grid view)"]
|
||||||
|
Playlists["Playlists<br/>(Grid view)"]
|
||||||
|
Genres["Genres<br/>(Genre browser)"]
|
||||||
|
|
||||||
|
Music --> Tracks
|
||||||
|
Music --> Artists
|
||||||
|
Music --> Albums
|
||||||
|
Music --> Playlists
|
||||||
|
Music --> Genres
|
||||||
|
```
|
||||||
|
|
||||||
|
**View Enforcement:**
|
||||||
|
|
||||||
|
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||||
|
|--------------|-----------|----------------|----------------|
|
||||||
|
| Tracks | List (forced) | No | `TrackList` |
|
||||||
|
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||||
|
|
||||||
|
**TrackList Component:**
|
||||||
|
|
||||||
|
The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:
|
||||||
|
|
||||||
|
- **No Thumbnails**: Track numbers only (transform to play button on hover)
|
||||||
|
- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration
|
||||||
|
- **Mobile Layout**: Compact rows with track number and metadata
|
||||||
|
- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility
|
||||||
|
- **Click Behavior**: Clicking a track plays it and queues all filtered tracks
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```svelte
|
||||||
|
<TrackList
|
||||||
|
tracks={filteredTracks}
|
||||||
|
loading={loading}
|
||||||
|
showArtist={true}
|
||||||
|
showAlbum={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**LibraryGrid forceGrid Prop:**
|
||||||
|
|
||||||
|
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||||
|
|
||||||
|
## Playback Reporting Service
|
||||||
|
|
||||||
|
**Location**: `src/lib/services/playbackReporting.ts`
|
||||||
|
|
||||||
|
The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:
|
||||||
|
- Offline "Continue Watching" functionality
|
||||||
|
- Sync queue for when network is unavailable
|
||||||
|
- Consistent progress across app restarts
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant VideoPlayer
|
||||||
|
participant PlaybackService as playbackReporting.ts
|
||||||
|
participant LocalDB as Local SQLite<br/>(Tauri Commands)
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
|
||||||
|
VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
|
||||||
|
|
||||||
|
par Local Storage (always works)
|
||||||
|
PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
|
||||||
|
LocalDB-->>PlaybackService: Ok (pending_sync = true)
|
||||||
|
and Server Sync (if online)
|
||||||
|
PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
|
||||||
|
Jellyfin-->>PlaybackService: Ok
|
||||||
|
PlaybackService->>LocalDB: invoke("storage_mark_synced")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service Functions:**
|
||||||
|
- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
|
||||||
|
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
|
||||||
|
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends
|
||||||
|
|
||||||
|
**Tauri Commands:**
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) |
|
||||||
|
| `storage_mark_played` | Mark item as played, increment play count |
|
||||||
|
| `storage_get_playback_progress` | Get stored progress for an item |
|
||||||
|
| `storage_mark_synced` | Clear `pending_sync` flag after successful server sync |
|
||||||
|
|
||||||
|
**Database Schema Notes:**
|
||||||
|
- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
|
||||||
|
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
|
||||||
|
|
||||||
|
**Resume Playback Feature:**
|
||||||
|
- When loading media for playback, the app checks local database for saved progress
|
||||||
|
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
|
||||||
|
- User can choose to "Resume" from saved position or "Start from Beginning"
|
||||||
|
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
|
||||||
|
- For audio: Seeks to resume position after loading via MPV backend
|
||||||
|
- Implemented in `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
## Repository Architecture (Rust-Based)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/repository/`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class MediaRepository {
|
||||||
|
<<trait>>
|
||||||
|
+get_libraries()
|
||||||
|
+get_items(parent_id, options)
|
||||||
|
+get_item(item_id)
|
||||||
|
+search(query, options)
|
||||||
|
+get_latest_items(parent_id, limit)
|
||||||
|
+get_resume_items(parent_id, limit)
|
||||||
|
+get_next_up_episodes(series_id, limit)
|
||||||
|
+get_genres(parent_id)
|
||||||
|
+get_playback_info(item_id)
|
||||||
|
+report_playback_start(item_id, position_ticks)
|
||||||
|
+report_playback_progress(item_id, position_ticks, is_paused)
|
||||||
|
+report_playback_stopped(item_id, position_ticks)
|
||||||
|
+mark_favorite(item_id)
|
||||||
|
+unmark_favorite(item_id)
|
||||||
|
+get_person(person_id)
|
||||||
|
+get_items_by_person(person_id, options)
|
||||||
|
+get_image_url(item_id, image_type, options)
|
||||||
|
+create_playlist(name, item_ids)
|
||||||
|
+delete_playlist(playlist_id)
|
||||||
|
+rename_playlist(playlist_id, name)
|
||||||
|
+get_playlist_items(playlist_id)
|
||||||
|
+add_to_playlist(playlist_id, item_ids)
|
||||||
|
+remove_from_playlist(playlist_id, entry_ids)
|
||||||
|
+move_playlist_item(playlist_id, item_id, new_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
class OnlineRepository {
|
||||||
|
-http_client: Arc~HttpClient~
|
||||||
|
-server_url: String
|
||||||
|
-user_id: String
|
||||||
|
-access_token: String
|
||||||
|
-connectivity: Option~Arc~ConnectivityMonitor~~
|
||||||
|
+new()
|
||||||
|
+with_connectivity()
|
||||||
|
-report_outcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineRepository {
|
||||||
|
-db_service: Arc~DatabaseService~
|
||||||
|
-server_id: String
|
||||||
|
-user_id: String
|
||||||
|
+new()
|
||||||
|
+cache_library()
|
||||||
|
+cache_items()
|
||||||
|
+cache_item()
|
||||||
|
}
|
||||||
|
|
||||||
|
class HybridRepository {
|
||||||
|
-online: Arc~OnlineRepository~
|
||||||
|
-offline: Arc~OfflineRepository~
|
||||||
|
+new()
|
||||||
|
-parallel_race()
|
||||||
|
-cache_with_timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaRepository <|.. OnlineRepository
|
||||||
|
MediaRepository <|.. OfflineRepository
|
||||||
|
MediaRepository <|.. HybridRepository
|
||||||
|
|
||||||
|
HybridRepository --> OnlineRepository
|
||||||
|
HybridRepository --> OfflineRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
|
||||||
|
1. **Cache-First Racing Strategy** (`hybrid.rs`):
|
||||||
|
- Runs cache (SQLite) and server (HTTP) queries in parallel
|
||||||
|
- Cache has 100ms timeout
|
||||||
|
- Returns cache result if it has meaningful content
|
||||||
|
- Falls back to server result otherwise
|
||||||
|
- 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):
|
||||||
|
```rust
|
||||||
|
// Frontend creates repository with UUID handle
|
||||||
|
repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
|
||||||
|
|
||||||
|
// All operations use handle for identification
|
||||||
|
repository_get_libraries(handle: String) -> Vec<Library>
|
||||||
|
repository_get_items(handle: String, ...) -> SearchResult
|
||||||
|
|
||||||
|
// Cleanup when done
|
||||||
|
repository_destroy(handle: String)
|
||||||
|
```
|
||||||
|
- Enables multiple concurrent repository instances
|
||||||
|
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
|
||||||
|
- No global state conflicts
|
||||||
|
|
||||||
|
3. **Frontend API Layer** (`src/lib/api/repository-client.ts`):
|
||||||
|
- Thin TypeScript wrapper over Rust commands
|
||||||
|
- Maintains handle throughout session
|
||||||
|
- All methods: `invoke<T>("repository_operation", { handle, ...args })`
|
||||||
|
- ~100 lines (down from 1061 lines)
|
||||||
|
|
||||||
|
## Playback Mode System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
||||||
|
|
||||||
|
The playback mode system manages transitions between local device playback and remote Jellyfin session control:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PlaybackMode {
|
||||||
|
Local, // Playing on local device
|
||||||
|
Remote { session_id: String }, // Controlling remote session
|
||||||
|
Idle, // Not playing
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlaybackModeManager {
|
||||||
|
current_mode: PlaybackMode,
|
||||||
|
player_controller: Arc<Mutex<PlayerController>>,
|
||||||
|
jellyfin_client: Arc<JellyfinClient>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Operations:**
|
||||||
|
|
||||||
|
1. **Transfer to Remote** (`transfer_to_remote(session_id)`):
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI
|
||||||
|
participant Manager as PlaybackModeManager
|
||||||
|
participant Player as PlayerController
|
||||||
|
participant Jellyfin as Jellyfin API
|
||||||
|
|
||||||
|
UI->>Manager: transfer_to_remote(session_id)
|
||||||
|
Manager->>Player: Extract queue items
|
||||||
|
Manager->>Manager: Get Jellyfin IDs from queue
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing
|
||||||
|
Note over Jellyfin: Start playback with queue
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
|
||||||
|
Note over Jellyfin: Seek to current position
|
||||||
|
Manager->>Player: Stop local playback
|
||||||
|
Manager->>Manager: Set mode to Remote
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`):
|
||||||
|
- Stops remote session playback
|
||||||
|
- Prepares local player to resume
|
||||||
|
- Sets mode to Local
|
||||||
|
|
||||||
|
**Tauri Commands** (`playback_mode.rs`):
|
||||||
|
- `playback_mode_get_current()` -> Returns current PlaybackMode
|
||||||
|
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
|
||||||
|
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
|
||||||
|
- `playback_mode_is_transferring()` -> Check transfer state
|
||||||
|
- `playback_mode_set(mode)` -> Direct mode setting
|
||||||
|
|
||||||
|
**Frontend Store** (`src/lib/stores/playbackMode.ts`):
|
||||||
|
- Thin wrapper calling Rust commands
|
||||||
|
- Maintains UI state (isTransferring, transferError)
|
||||||
|
- Listens to mode change events from Rust
|
||||||
|
|
||||||
|
## Database Service Abstraction
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/storage/db_service.rs`
|
||||||
|
|
||||||
|
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DatabaseService: Send + Sync {
|
||||||
|
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
||||||
|
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
||||||
|
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
||||||
|
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
||||||
|
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
||||||
|
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
||||||
|
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RusqliteService {
|
||||||
|
connection: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseService for RusqliteService {
|
||||||
|
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
||||||
|
let conn = self.connection.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// Execute query on blocking thread pool
|
||||||
|
}).await?
|
||||||
|
}
|
||||||
|
// ... other methods use spawn_blocking
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits:**
|
||||||
|
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
||||||
|
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
||||||
|
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
||||||
|
- **Testable**: Can mock DatabaseService for tests
|
||||||
|
|
||||||
|
**Usage Pattern:**
|
||||||
|
```rust
|
||||||
|
// Before (blocking - causes UI freeze)
|
||||||
|
let conn = database.connection();
|
||||||
|
let conn = conn.lock().unwrap(); // BLOCKS
|
||||||
|
conn.query_row(...) // BLOCKS
|
||||||
|
|
||||||
|
// After (async - no freezing)
|
||||||
|
let db_service = database.service();
|
||||||
|
let query = Query::with_params("SELECT ...", vec![...]);
|
||||||
|
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Routes["Routes (src/routes/)"]
|
||||||
|
LoginPage["Login Page"]
|
||||||
|
LibLayout["Library Layout"]
|
||||||
|
LibDetail["Album/Series Detail"]
|
||||||
|
MusicCategory["Music Category Landing"]
|
||||||
|
Tracks["Tracks"]
|
||||||
|
Artists["Artists"]
|
||||||
|
Albums["Albums"]
|
||||||
|
Playlists["Playlists"]
|
||||||
|
Genres["Genres"]
|
||||||
|
Downloads["Downloads Page"]
|
||||||
|
Settings["Settings Page"]
|
||||||
|
PlayerPage["Player Page"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PlayerComps["Player Components"]
|
||||||
|
AudioPlayer["AudioPlayer"]
|
||||||
|
VideoPlayer["VideoPlayer"]
|
||||||
|
MiniPlayer["MiniPlayer"]
|
||||||
|
Controls["Controls"]
|
||||||
|
Queue["Queue"]
|
||||||
|
SleepTimerModal["SleepTimerModal"]
|
||||||
|
SleepTimerIndicator["SleepTimerIndicator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SessionComps["Sessions Components"]
|
||||||
|
CastButton["CastButton"]
|
||||||
|
SessionModal["SessionPickerModal"]
|
||||||
|
SessionCard["SessionCard"]
|
||||||
|
SessionsList["SessionsList"]
|
||||||
|
RemoteControls["RemoteControls"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph LibraryComps["Library Components"]
|
||||||
|
LibGrid["LibraryGrid"]
|
||||||
|
LibListView["LibraryListView"]
|
||||||
|
TrackList["TrackList"]
|
||||||
|
PlaylistDetail["PlaylistDetailView"]
|
||||||
|
DownloadBtn["DownloadButton"]
|
||||||
|
MediaCard["MediaCard"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PlaylistComps["Playlist Components"]
|
||||||
|
CreatePlaylistModal["CreatePlaylistModal"]
|
||||||
|
AddToPlaylistModal["AddToPlaylistModal"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CommonComps["Common Components"]
|
||||||
|
ScrollPicker["ScrollPicker"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OtherComps["Other Components"]
|
||||||
|
Search["Search"]
|
||||||
|
FavoriteBtn["FavoriteButton"]
|
||||||
|
DownloadItem["DownloadItem"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LibLayout --> PlayerComps
|
||||||
|
LibLayout --> LibDetail
|
||||||
|
MusicCategory --> Tracks
|
||||||
|
MusicCategory --> Artists
|
||||||
|
MusicCategory --> Albums
|
||||||
|
MusicCategory --> Playlists
|
||||||
|
MusicCategory --> Genres
|
||||||
|
LibDetail --> LibraryComps
|
||||||
|
Playlists --> PlaylistComps
|
||||||
|
Playlists --> PlaylistDetail
|
||||||
|
Downloads --> DownloadItem
|
||||||
|
PlayerPage --> PlayerComps
|
||||||
|
|
||||||
|
MiniPlayer --> CastButton
|
||||||
|
CastButton --> SessionModal
|
||||||
|
SleepTimerModal --> ScrollPicker
|
||||||
|
PlayerComps --> LibraryComps
|
||||||
|
```
|
||||||
|
|
||||||
|
## MiniPlayer Behavior
|
||||||
|
|
||||||
|
**Location**: `src/lib/components/player/MiniPlayer.svelte`
|
||||||
|
|
||||||
|
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
|
||||||
|
|
||||||
|
**Touch Gesture Handling:**
|
||||||
|
|
||||||
|
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchEndX = touchStartX; // Initialize to start position
|
||||||
|
touchEndY = touchStartY; // Prevents taps being treated as swipes
|
||||||
|
isSwiping = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
|
||||||
|
|
||||||
|
**Skip Button State:**
|
||||||
|
|
||||||
|
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
||||||
|
|
||||||
|
## Sleep Timer Architecture
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-026 | DR-029
|
||||||
|
|
||||||
|
The sleep timer supports three modes for stopping playback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum SleepTimerMode {
|
||||||
|
Off,
|
||||||
|
Time { end_time: i64 }, // Unix timestamp in milliseconds
|
||||||
|
EndOfTrack, // Stop after current track/episode
|
||||||
|
Episodes { remaining: u32 }, // Stop after N more episodes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timer Modes:**
|
||||||
|
|
||||||
|
| Mode | Trigger | How It Stops |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
|
||||||
|
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
|
||||||
|
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
|
||||||
|
|
||||||
|
**Time-Based Timer Flow:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as SleepTimerModal
|
||||||
|
participant Store as sleepTimer store
|
||||||
|
participant Rust as PlayerController
|
||||||
|
participant Thread as Timer Thread
|
||||||
|
participant Backend as PlayerBackend
|
||||||
|
|
||||||
|
UI->>Store: setTimeTimer(30)
|
||||||
|
Store->>Rust: invoke("player_set_sleep_timer", {mode})
|
||||||
|
Rust->>Rust: Set SleepTimerMode::Time { end_time }
|
||||||
|
Rust->>UI: Emit SleepTimerChanged event
|
||||||
|
|
||||||
|
loop Every 1 second
|
||||||
|
Thread->>Thread: update_remaining_seconds()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (countdown)
|
||||||
|
alt remaining_seconds == 0
|
||||||
|
Thread->>Backend: stop()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (Off)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Components:**
|
||||||
|
|
||||||
|
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
|
||||||
|
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
|
||||||
|
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
|
||||||
|
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
1. **All logic in Rust**: Frontend only displays state and invokes commands
|
||||||
|
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
|
||||||
|
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
|
||||||
|
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
|
||||||
|
|
||||||
|
## Auto-Play Episode Limit
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-023 | DR-049
|
||||||
|
|
||||||
|
Limits how many episodes auto-play consecutively before requiring manual intervention.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In AutoplaySettings (runtime, in PlayerController)
|
||||||
|
pub struct AutoplaySettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub countdown_seconds: u32,
|
||||||
|
pub max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
// In VideoSettings (persisted, settings page)
|
||||||
|
pub struct VideoSettings {
|
||||||
|
pub auto_play_next_episode: bool,
|
||||||
|
pub auto_play_countdown_seconds: u32,
|
||||||
|
pub auto_play_max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session-Based Counter:**
|
||||||
|
|
||||||
|
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
|
||||||
|
|
||||||
|
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
|
||||||
|
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
|
||||||
|
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
|
||||||
|
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
|
||||||
|
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
|
||||||
|
FetchNext --> IncrementCount["increment_autoplay_count()"]
|
||||||
|
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
|
||||||
|
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
|
||||||
|
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
|
||||||
|
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
|
||||||
|
UserClick --> PlayItem["play_item() -> resets counter"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Settings Sync:**
|
||||||
|
|
||||||
|
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
|
||||||
|
|
||||||
|
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
|
||||||
|
|
||||||
|
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
|
||||||
|
|
||||||
|
## Player Page Navigation Guard
|
||||||
|
|
||||||
|
**Location**: `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
|
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
||||||
|
// Track already playing - show UI without restarting playback
|
||||||
|
// Fetch queue status for hasNext/hasPrevious
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
|
||||||
|
|
||||||
|
## Playlist Management UI
|
||||||
|
|
||||||
|
**TRACES**: UR-014 | JA-019 | JA-020
|
||||||
|
|
||||||
|
**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`
|
||||||
|
|
||||||
|
The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
|
||||||
|
- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`):
|
||||||
|
- Modal for creating new playlists with a name input
|
||||||
|
- Accepts optional `initialItemIds` to pre-populate with tracks
|
||||||
|
- Keyboard support: Enter to create, Escape to close
|
||||||
|
- Navigates to new playlist detail page on creation
|
||||||
|
|
||||||
|
- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`):
|
||||||
|
- Modal listing all existing playlists to add tracks to
|
||||||
|
- "New Playlist" button for inline creation flow
|
||||||
|
- Shows playlist artwork via CachedImage
|
||||||
|
- Loading state with skeleton placeholders
|
||||||
|
|
||||||
|
- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`):
|
||||||
|
- Full playlist detail page with artwork, name, track count, total duration
|
||||||
|
- Click-to-rename with inline editing
|
||||||
|
- Play all / shuffle play buttons
|
||||||
|
- Delete with confirmation dialog
|
||||||
|
- Per-track removal buttons
|
||||||
|
- Uses `TrackList` component for track display
|
||||||
|
- Passes `{ type: "playlist", playlistId, playlistName }` context to player
|
||||||
|
|
||||||
|
- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`):
|
||||||
|
- Grid view using `GenericMediaListPage`
|
||||||
|
- Floating action button (FAB) to create new playlists
|
||||||
|
- Search by playlist name
|
||||||
|
|
||||||
|
**Frontend API Methods** (`src/lib/api/repository-client.ts`):
|
||||||
|
- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
|
||||||
|
- `deletePlaylist(playlistId)`
|
||||||
|
- `renamePlaylist(playlistId, name)`
|
||||||
|
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
|
||||||
|
- `addToPlaylist(playlistId, itemIds)`
|
||||||
|
- `removeFromPlaylist(playlistId, entryIds)`
|
||||||
|
- `movePlaylistItem(playlistId, itemId, newIndex)`
|
||||||
|
|
||||||
|
**Offline Sync** (`src/lib/services/syncService.ts`):
|
||||||
|
All playlist mutations are queued for offline sync:
|
||||||
|
- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
|
||||||
|
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Data Flow
|
||||||
|
|
||||||
|
## Repository Query Flow (Cache-First)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as Svelte Component
|
||||||
|
participant Client as RepositoryClient (TS)
|
||||||
|
participant Rust as Tauri Command
|
||||||
|
participant Hybrid as HybridRepository
|
||||||
|
participant Cache as OfflineRepository (SQLite)
|
||||||
|
participant Server as OnlineRepository (HTTP)
|
||||||
|
participant Conn as ConnectivityMonitor
|
||||||
|
|
||||||
|
UI->>Client: getItems(parentId)
|
||||||
|
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
||||||
|
Rust->>Hybrid: get_items()
|
||||||
|
|
||||||
|
par Parallel Racing
|
||||||
|
Hybrid->>Cache: get_items() with 100ms timeout
|
||||||
|
Hybrid->>Server: get_items() (no timeout)
|
||||||
|
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
|
||||||
|
Cache-->>Hybrid: Result with items
|
||||||
|
Hybrid-->>Rust: Return cache result
|
||||||
|
else Cache timeout or empty
|
||||||
|
Server-->>Hybrid: Fresh result
|
||||||
|
Hybrid-->>Rust: Return server result
|
||||||
|
end
|
||||||
|
|
||||||
|
Rust-->>Client: SearchResult
|
||||||
|
Client-->>UI: items[]
|
||||||
|
Note over UI: Reactive update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- Cache queries have 100ms timeout for responsiveness
|
||||||
|
- Server queries always run for fresh data
|
||||||
|
- Cache wins if it has meaningful content
|
||||||
|
- Automatic fallback to server if cache is empty/stale
|
||||||
|
- 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
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant AudioPlayer
|
||||||
|
participant Tauri as Tauri IPC
|
||||||
|
participant Command as player_play_item()
|
||||||
|
participant Controller as PlayerController
|
||||||
|
participant Backend as PlayerBackend
|
||||||
|
participant Store as Frontend Store
|
||||||
|
|
||||||
|
User->>AudioPlayer: clicks play
|
||||||
|
AudioPlayer->>Tauri: invoke("player_play_item", {item})
|
||||||
|
Tauri->>Command: player_play_item()
|
||||||
|
Command->>Command: Convert PlayItemRequest -> MediaItem
|
||||||
|
Command->>Controller: play_item(item)
|
||||||
|
Controller->>Backend: load(item)
|
||||||
|
Note over Backend: State -> Loading
|
||||||
|
Controller->>Backend: play()
|
||||||
|
Note over Backend: State -> Playing
|
||||||
|
Controller-->>Command: Ok(())
|
||||||
|
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
|
||||||
|
Tauri-->>AudioPlayer: status
|
||||||
|
AudioPlayer->>Store: player.setPlaying(media, position, duration)
|
||||||
|
Note over Store: UI updates reactively
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback Mode Transfer Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as Cast Button
|
||||||
|
participant Store as playbackMode store
|
||||||
|
participant Rust as Tauri Command
|
||||||
|
participant Manager as PlaybackModeManager
|
||||||
|
participant Player as PlayerController
|
||||||
|
participant Jellyfin as Jellyfin API
|
||||||
|
|
||||||
|
UI->>Store: transferToRemote(sessionId)
|
||||||
|
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
|
||||||
|
Rust->>Manager: transfer_to_remote()
|
||||||
|
|
||||||
|
Manager->>Player: Get current queue
|
||||||
|
Player-->>Manager: Vec<MediaItem>
|
||||||
|
Manager->>Manager: Extract Jellyfin IDs
|
||||||
|
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
|
||||||
|
Jellyfin-->>Manager: 200 OK
|
||||||
|
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
|
||||||
|
Jellyfin-->>Manager: 200 OK
|
||||||
|
|
||||||
|
Manager->>Player: stop()
|
||||||
|
Manager->>Manager: mode = Remote {sessionId}
|
||||||
|
|
||||||
|
Manager-->>Rust: Ok(())
|
||||||
|
Rust-->>Store: PlaybackMode
|
||||||
|
Store->>UI: Update cast icon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Queue Navigation Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User["User clicks Next"] --> Invoke["invoke('player_next')"]
|
||||||
|
Invoke --> ControllerNext["controller.next()"]
|
||||||
|
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
|
||||||
|
|
||||||
|
QueueNext --> None["None<br/>(at end)"]
|
||||||
|
QueueNext --> Some["Some(next)"]
|
||||||
|
QueueNext --> Same["Same<br/>(repeat one)"]
|
||||||
|
|
||||||
|
Some --> PlayItem["play_item(next)<br/>Returns new status"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volume Control Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Slider as Volume Slider
|
||||||
|
participant Handler as handleVolumeChange()
|
||||||
|
participant Tauri as Tauri IPC
|
||||||
|
participant Command as player_set_volume
|
||||||
|
participant Controller as PlayerController
|
||||||
|
participant Backend as MpvBackend/NullBackend
|
||||||
|
participant Events as playerEvents.ts
|
||||||
|
participant Store as Player Store
|
||||||
|
participant UI
|
||||||
|
|
||||||
|
User->>Slider: adjusts (0-100)
|
||||||
|
Slider->>Handler: oninput event
|
||||||
|
Handler->>Handler: Convert 0-100 -> 0.0-1.0
|
||||||
|
Handler->>Tauri: invoke("player_set_volume", {volume})
|
||||||
|
Tauri->>Command: player_set_volume
|
||||||
|
Command->>Controller: set_volume(volume)
|
||||||
|
Controller->>Backend: set_volume(volume)
|
||||||
|
Backend->>Backend: Clamp to 0.0-1.0
|
||||||
|
Note over Backend: MpvBackend: Send to MPV loop
|
||||||
|
Backend-->>Tauri: emit "player-event"
|
||||||
|
Tauri-->>Events: VolumeChanged event
|
||||||
|
Events->>Store: player.setVolume(volume)
|
||||||
|
Store-->>UI: Reactive update
|
||||||
|
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
- Volume is stored in the backend (NullBackend/MpvBackend)
|
||||||
|
- `PlayerController.volume()` delegates to backend
|
||||||
|
- `get_player_status()` returns `controller.volume()` (not hardcoded)
|
||||||
|
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Type Synchronization & Thread Safety
|
||||||
|
|
||||||
|
## PlayerState (Rust <-> TypeScript)
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerState {
|
||||||
|
Idle,
|
||||||
|
Loading { media: MediaItem },
|
||||||
|
Playing { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Paused { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Seeking { media: MediaItem, target: f64 },
|
||||||
|
Error { media: Option<MediaItem>, error: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
type PlayerState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "loading"; media: MediaItem }
|
||||||
|
| { kind: "playing"; media: MediaItem; position: number; duration: number }
|
||||||
|
| { kind: "paused"; media: MediaItem; position: number; duration: number }
|
||||||
|
| { kind: "seeking"; media: MediaItem; target: number }
|
||||||
|
| { kind: "error"; media: MediaItem | null; error: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
## MediaItem Serialization
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust (serde serialization)
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TypeScript
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tauri v2 IPC Parameter Naming Convention
|
||||||
|
|
||||||
|
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
|
||||||
|
|
||||||
|
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repositoryHandle: "handle-123", // Rust: repository_handle
|
||||||
|
request: { trackIds: ["id1"], startIndex: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("remote_send_command", {
|
||||||
|
sessionId: "session-123", // Rust: session_id
|
||||||
|
command: "PlayPause"
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("pin_item", {
|
||||||
|
itemId: "item-123" // Rust: item_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// WRONG - snake_case causes "invalid args request" error on Android
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repository_handle: "handle-123", // Will fail!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter Name Mapping (Rust -> Frontend)**:
|
||||||
|
|
||||||
|
| Rust Parameter | Frontend Parameter | Used By |
|
||||||
|
|----------------|-------------------|----|
|
||||||
|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
|
||||||
|
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
|
||||||
|
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
|
||||||
|
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
|
||||||
|
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
|
||||||
|
| `item_ids` | `itemIds` | `remote_play_on_session` |
|
||||||
|
| `start_index` | `startIndex` | `remote_play_on_session` |
|
||||||
|
|
||||||
|
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
|
||||||
|
|
||||||
|
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
|
||||||
|
|
||||||
|
## Rust Backend Thread Safety
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Shared state wrapped in Arc<Mutex<>>
|
||||||
|
pub struct PlayerController {
|
||||||
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||||
|
queue: Arc<Mutex<QueueManager>>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tauri state wrapper
|
||||||
|
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
|
||||||
|
|
||||||
|
// Command handler pattern
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
|
||||||
|
let mut controller = state.0.lock().unwrap(); // Acquire lock
|
||||||
|
controller.play()?; // Operate
|
||||||
|
Ok(get_player_status(&controller)) // Lock released
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Stores
|
||||||
|
|
||||||
|
Svelte stores are inherently reactive and thread-safe for UI updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { subscribe, update } = writable<PlayerStore>(initialState);
|
||||||
|
|
||||||
|
// Atomic updates
|
||||||
|
function setPlaying(media: MediaItem, position: number, duration: number) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
state: { kind: "playing", media, position, duration }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Platform-Specific Player Backends
|
||||||
|
|
||||||
|
## Player Events System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/events.rs`
|
||||||
|
|
||||||
|
The player uses a push-based event system to notify the frontend of state changes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PlayerStatusEvent {
|
||||||
|
/// Playback position updated (emitted periodically during playback)
|
||||||
|
PositionUpdate { position: f64, duration: f64 },
|
||||||
|
|
||||||
|
/// Player state changed
|
||||||
|
StateChanged { state: String, media_id: Option<String> },
|
||||||
|
|
||||||
|
/// Media has finished loading and is ready to play
|
||||||
|
MediaLoaded { duration: f64 },
|
||||||
|
|
||||||
|
/// Playback has ended naturally
|
||||||
|
PlaybackEnded,
|
||||||
|
|
||||||
|
/// Buffering state changed
|
||||||
|
Buffering { percent: u8 },
|
||||||
|
|
||||||
|
/// An error occurred during playback
|
||||||
|
Error { message: String, recoverable: bool },
|
||||||
|
|
||||||
|
/// Volume changed
|
||||||
|
VolumeChanged { volume: f32, muted: bool },
|
||||||
|
|
||||||
|
/// Sleep timer state changed
|
||||||
|
SleepTimerChanged {
|
||||||
|
mode: SleepTimerMode,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show next episode popup with countdown
|
||||||
|
ShowNextEpisodePopup {
|
||||||
|
current_episode: MediaItem,
|
||||||
|
next_episode: MediaItem,
|
||||||
|
countdown_seconds: u32,
|
||||||
|
auto_advance: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Countdown tick (emitted every second during autoplay countdown)
|
||||||
|
CountdownTick { remaining_seconds: u32 },
|
||||||
|
|
||||||
|
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
||||||
|
QueueChanged {
|
||||||
|
items: Vec<MediaItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
shuffle: bool,
|
||||||
|
repeat: RepeatMode,
|
||||||
|
has_next: bool,
|
||||||
|
has_previous: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
||||||
|
SessionChanged { session: MediaSessionType },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are emitted via Tauri's event system:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Backend["Player Backend"]
|
||||||
|
MPV["MPV/ExoPlayer"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EventSystem["Event System"]
|
||||||
|
Emitter["TauriEventEmitter<br/>emit()"]
|
||||||
|
Bus["Tauri Event Bus<br/>'player-event'"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
Listener["playerEvents.ts<br/>Frontend Listener"]
|
||||||
|
Store["Player Store Update<br/>(position, state, etc)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MPV --> Emitter --> Bus --> Listener --> Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
|
||||||
|
- Listens for `player-event` Tauri events
|
||||||
|
- Updates player/queue stores based on event type
|
||||||
|
- Auto-advances to next track on `PlaybackEnded`
|
||||||
|
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
|
||||||
|
|
||||||
|
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||||
|
|
||||||
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mpv/`
|
||||||
|
|
||||||
|
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph MainThread["Main Thread"]
|
||||||
|
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
|
||||||
|
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EventLoopThread["MPV Event Loop Thread"]
|
||||||
|
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
|
||||||
|
TauriEmitter["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MpvBackend -->|"MpvCommand"| EventLoop
|
||||||
|
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
|
||||||
|
EventLoop -->|"Events"| TauriEmitter
|
||||||
|
TauriEmitter --> FrontendStore["Frontend Store"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Components:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Command enum sent to event loop thread
|
||||||
|
pub enum MpvCommand {
|
||||||
|
Load { url: String, media: MediaItem },
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
Seek(f64),
|
||||||
|
SetVolume(f32),
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared state between main thread and event loop
|
||||||
|
pub struct MpvSharedState {
|
||||||
|
pub state: PlayerState,
|
||||||
|
pub position: f64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub volume: f32,
|
||||||
|
pub is_loaded: bool,
|
||||||
|
pub current_media: Option<MediaItem>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Loop** (`event_loop.rs`):
|
||||||
|
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
|
||||||
|
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
|
||||||
|
- Emits position updates every 250ms during playback
|
||||||
|
- Processes commands from channel (non-blocking)
|
||||||
|
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
|
||||||
|
|
||||||
|
## ExoPlayerBackend (Android)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/android/` and Kotlin sources
|
||||||
|
|
||||||
|
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph RustNative["Rust (Native)"]
|
||||||
|
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
|
||||||
|
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
|
||||||
|
TauriEmitter2["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph KotlinJVM["Kotlin (JVM)"]
|
||||||
|
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
|
||||||
|
end
|
||||||
|
|
||||||
|
ExoBackend -->|"JNI Calls"| JellyTauPlayer
|
||||||
|
JellyTauPlayer -->|"Callbacks"| NativeFuncs
|
||||||
|
NativeFuncs --> TauriEmitter2
|
||||||
|
TauriEmitter2 --> FrontendStore2["Frontend Store"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kotlin Player** (`JellyTauPlayer.kt`):
|
||||||
|
```kotlin
|
||||||
|
class JellyTauPlayer(context: Context) {
|
||||||
|
private val exoPlayer: ExoPlayer
|
||||||
|
private var positionUpdateJob: Job?
|
||||||
|
|
||||||
|
// Methods callable from Rust via JNI
|
||||||
|
fun load(url: String, mediaId: String)
|
||||||
|
fun play()
|
||||||
|
fun pause()
|
||||||
|
fun stop()
|
||||||
|
fun seek(positionSeconds: Double)
|
||||||
|
fun setVolume(volume: Float)
|
||||||
|
|
||||||
|
// Native callbacks to Rust
|
||||||
|
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||||||
|
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||||||
|
private external fun nativeOnMediaLoaded(duration: Double)
|
||||||
|
private external fun nativeOnPlaybackEnded()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**JNI Callbacks** (Rust):
|
||||||
|
```rust
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
|
||||||
|
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
|
||||||
|
) {
|
||||||
|
// Update shared state
|
||||||
|
// Emit PlayerStatusEvent::PositionUpdate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android MediaSession & Remote Volume Control
|
||||||
|
|
||||||
|
**Location**: `JellyTauPlaybackService.kt`
|
||||||
|
|
||||||
|
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Service["JellyTauPlaybackService"]
|
||||||
|
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
|
||||||
|
|
||||||
|
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
|
||||||
|
|
||||||
|
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
|
||||||
|
|
||||||
|
MediaSessionCompat --> VolumeProvider
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Hardware["System"]
|
||||||
|
VolumeButtons["Hardware Volume Buttons"]
|
||||||
|
Lockscreen["Lockscreen Controls"]
|
||||||
|
Notification["Media Notification"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Rust["Rust Backend"]
|
||||||
|
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
|
||||||
|
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
|
||||||
|
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
|
||||||
|
end
|
||||||
|
|
||||||
|
VolumeButtons --> VolumeProvider
|
||||||
|
Lockscreen --> MediaSession
|
||||||
|
Notification --> MediaSession
|
||||||
|
|
||||||
|
VolumeProvider --> JNI
|
||||||
|
JNI --> PlaybackMode
|
||||||
|
PlaybackMode --> JellyfinAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
**Architecture Rationale:**
|
||||||
|
|
||||||
|
JellyTau maintains both MediaSession types because they serve different purposes:
|
||||||
|
|
||||||
|
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
|
||||||
|
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
|
||||||
|
|
||||||
|
When in remote playback mode (controlling a Jellyfin session on another device):
|
||||||
|
- Volume buttons are routed through `VolumeProviderCompat`
|
||||||
|
- Volume changes are sent to the remote session via Jellyfin API
|
||||||
|
- System volume UI shows the remote session's volume level
|
||||||
|
|
||||||
|
**Remote Volume Flow:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant VolumeButton as Hardware Volume Button
|
||||||
|
participant VolumeProvider as VolumeProviderCompat
|
||||||
|
participant JNI as nativeOnRemoteVolumeChange
|
||||||
|
participant PlaybackMode as PlaybackModeManager
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
participant RemoteSession as Remote Session (TV/Browser)
|
||||||
|
|
||||||
|
User->>VolumeButton: Press Volume Up
|
||||||
|
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
|
||||||
|
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
|
||||||
|
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
|
||||||
|
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
|
||||||
|
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
|
||||||
|
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
|
||||||
|
Jellyfin->>RemoteSession: Set volume to new level
|
||||||
|
RemoteSession-->>User: Volume changes on TV/Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
|
||||||
|
**Enabling Remote Volume** (`enableRemoteVolume()`):
|
||||||
|
```kotlin
|
||||||
|
fun enableRemoteVolume(initialVolume: Int) {
|
||||||
|
volumeProvider = object : VolumeProviderCompat(
|
||||||
|
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
|
||||||
|
100, // Max volume
|
||||||
|
initialVolume
|
||||||
|
) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
when (direction) {
|
||||||
|
AudioManager.ADJUST_RAISE -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
||||||
|
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
AudioManager.ADJUST_LOWER -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
||||||
|
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disabling Remote Volume** (`disableRemoteVolume()`):
|
||||||
|
```kotlin
|
||||||
|
fun disableRemoteVolume() {
|
||||||
|
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||||||
|
volumeProvider = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
|
||||||
|
```rust
|
||||||
|
/// Enable remote volume control on Android
|
||||||
|
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||||
|
start_playback_service()?;
|
||||||
|
let service_instance = get_playback_service_instance()?;
|
||||||
|
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
|
||||||
|
&[JValue::Int(initial_volume)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependencies** (`src-tauri/android/build.gradle.kts`):
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
|
||||||
|
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration with Playback Mode:**
|
||||||
|
|
||||||
|
Remote volume is automatically enabled/disabled during playback mode transfers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In PlaybackModeManager::transfer_to_remote()
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||||
|
log::warn!("Failed to enable remote volume: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In PlaybackModeManager::transfer_to_local()
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::disable_remote_volume() {
|
||||||
|
log::warn!("Failed to disable remote volume: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android Album Art Caching
|
||||||
|
|
||||||
|
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
|
||||||
|
|
||||||
|
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
||||||
|
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
||||||
|
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache["AlbumArtCache.kt"]
|
||||||
|
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
|
||||||
|
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
|
||||||
|
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Service["JellyTauPlaybackService.kt"]
|
||||||
|
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
|
||||||
|
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LoadMedia --> AsyncDownload
|
||||||
|
AsyncDownload --> MemoryCache
|
||||||
|
MemoryCache --> Download
|
||||||
|
Download --> ErrorHandle
|
||||||
|
AsyncDownload --> UpdateMeta
|
||||||
|
UpdateMeta --> Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
**AlbumArtCache Singleton:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class AlbumArtCache(context: Context) {
|
||||||
|
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||||
|
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||||
|
return bitmap.byteCount / 1024 // Size in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getArtwork(url: String): Bitmap? {
|
||||||
|
memoryCache.get(url)?.let { return it }
|
||||||
|
return downloadAndCache(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAndCache(url: String): Bitmap? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// HTTP download with 5s timeout
|
||||||
|
// Scale to 512x512 max
|
||||||
|
// Auto-evict LRU if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration Flow:**
|
||||||
|
|
||||||
|
1. **Track Load** (`loadWithMetadata()`):
|
||||||
|
- Store artwork URL in `currentArtworkUrl`
|
||||||
|
- Reset bitmap to null
|
||||||
|
- Start playback immediately (non-blocking)
|
||||||
|
|
||||||
|
2. **Async Download** (Background Coroutine):
|
||||||
|
- Check cache: instant hit if available
|
||||||
|
- Network miss: download, scale, cache
|
||||||
|
- Auto-retry on network failure with exponential backoff
|
||||||
|
- Graceful fallback if artwork unavailable
|
||||||
|
|
||||||
|
3. **Notification Update**:
|
||||||
|
- Pass bitmap to `updatePlaybackServiceNotification()`
|
||||||
|
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
|
||||||
|
- Display as large icon in notification
|
||||||
|
- Show on lock screen
|
||||||
|
|
||||||
|
**Memory Management:**
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Cache Size | 1/8 of heap (12-16MB typical) |
|
||||||
|
| Max Resolution | 512x512 pixels |
|
||||||
|
| Capacity | ~50-100 album arts |
|
||||||
|
| Eviction Policy | LRU (Least Recently Used) |
|
||||||
|
| Lifetime | In-memory only (app session) |
|
||||||
|
| Network Timeout | 5 seconds per download |
|
||||||
|
|
||||||
|
**Performance Characteristics:**
|
||||||
|
|
||||||
|
- **Cache Hit**: ~1ms (in-memory retrieval)
|
||||||
|
- **Cache Miss**: ~200-500ms (download + scale)
|
||||||
|
- **Playback Impact**: Zero (async downloads)
|
||||||
|
- **Memory Overhead**: Max 16MB (auto-eviction)
|
||||||
|
- **Error Recovery**: Automatic with exponential backoff
|
||||||
|
|
||||||
|
## Backend Initialization
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
Backend selection is platform-specific:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
|
||||||
|
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
match MpvBackend::new(event_emitter.clone()) {
|
||||||
|
Ok(backend) => return Box::new(backend),
|
||||||
|
Err(e) => eprintln!("MPV init failed: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
// ExoPlayer requires Activity context, initialized separately
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
Box::new(NullBackend::new())
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Download Manager & Offline Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/`
|
||||||
|
|
||||||
|
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
DownloadButton["DownloadButton.svelte"]
|
||||||
|
DownloadsPage["/downloads"]
|
||||||
|
DownloadsStore["downloads.ts store"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend["Rust Backend"]
|
||||||
|
Commands["Download Commands"]
|
||||||
|
DownloadManager["DownloadManager"]
|
||||||
|
DownloadWorker["DownloadWorker"]
|
||||||
|
SmartCache["SmartCache Engine"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage["Storage"]
|
||||||
|
SQLite[("SQLite DB")]
|
||||||
|
MediaFiles[("Downloaded Files")]
|
||||||
|
end
|
||||||
|
|
||||||
|
DownloadButton -->|"invoke('download_item')"| Commands
|
||||||
|
DownloadsPage -->|"invoke('get_downloads')"| Commands
|
||||||
|
Commands --> DownloadManager
|
||||||
|
DownloadManager --> DownloadWorker
|
||||||
|
DownloadManager --> SmartCache
|
||||||
|
DownloadWorker -->|"HTTP Stream"| MediaFiles
|
||||||
|
DownloadWorker -->|"Events"| DownloadsStore
|
||||||
|
Commands <--> SQLite
|
||||||
|
SmartCache <--> SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Worker
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/worker.rs`
|
||||||
|
|
||||||
|
The download worker handles HTTP streaming with retry logic and resume support:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct DownloadWorker {
|
||||||
|
client: reqwest::Client,
|
||||||
|
max_retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DownloadTask {
|
||||||
|
pub id: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub priority: i32,
|
||||||
|
pub url: String,
|
||||||
|
pub target_path: PathBuf,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub expected_size: Option<i64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry Strategy**:
|
||||||
|
- Exponential backoff: 5s, 15s, 45s
|
||||||
|
- Maximum 3 retry attempts
|
||||||
|
- HTTP Range requests for resume support
|
||||||
|
- Progress events emitted every 1MB
|
||||||
|
|
||||||
|
**Download Flow**:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI
|
||||||
|
participant Command as download_item
|
||||||
|
participant DB as SQLite
|
||||||
|
participant Worker as DownloadWorker
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
participant Store as downloads store
|
||||||
|
|
||||||
|
UI->>Command: download_item(itemId, userId)
|
||||||
|
Command->>DB: INSERT INTO downloads
|
||||||
|
Command->>Worker: Start download task
|
||||||
|
Worker->>Jellyfin: GET /Items/{id}/Download
|
||||||
|
|
||||||
|
loop Progress Updates
|
||||||
|
Jellyfin->>Worker: Stream chunks
|
||||||
|
Worker->>Worker: Write to .part file
|
||||||
|
Worker->>Store: Emit progress event
|
||||||
|
Store->>UI: Update progress bar
|
||||||
|
end
|
||||||
|
|
||||||
|
Worker->>Worker: Rename .part to final
|
||||||
|
Worker->>DB: UPDATE status='completed'
|
||||||
|
Worker->>Store: Emit completed event
|
||||||
|
Store->>UI: Show completed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smart Caching Engine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/cache.rs`
|
||||||
|
|
||||||
|
The smart caching system provides predictive downloads based on listening patterns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SmartCache {
|
||||||
|
config: Arc<Mutex<CacheConfig>>,
|
||||||
|
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheConfig {
|
||||||
|
pub queue_precache_enabled: bool,
|
||||||
|
pub queue_precache_count: usize, // Default: 5
|
||||||
|
pub album_affinity_enabled: bool,
|
||||||
|
pub album_affinity_threshold: usize, // Default: 3
|
||||||
|
pub storage_limit: u64, // Default: 10GB
|
||||||
|
pub wifi_only: bool, // Default: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caching Strategies**:
|
||||||
|
|
||||||
|
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
|
||||||
|
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
|
||||||
|
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
|
||||||
|
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
|
||||||
|
|
||||||
|
Play --> TrackHistory["Track Play History"]
|
||||||
|
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
|
||||||
|
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
|
||||||
|
|
||||||
|
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
|
||||||
|
CacheAlbum --> CheckStorage
|
||||||
|
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
|
||||||
|
CheckStorage -->|"OK"| Download["Queue Download"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Commands
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/download.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
|
||||||
|
| `download_album` | `album_id, user_id` | Queue all tracks in album |
|
||||||
|
| `get_downloads` | `user_id, status_filter` | Get download list |
|
||||||
|
| `pause_download` | `download_id` | Pause active download |
|
||||||
|
| `resume_download` | `download_id` | Resume paused download |
|
||||||
|
| `cancel_download` | `download_id` | Cancel and delete partial |
|
||||||
|
| `delete_download` | `download_id` | Delete completed download |
|
||||||
|
|
||||||
|
## Offline Commands
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/offline.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| `offline_is_available` | `item_id` | Check if item downloaded |
|
||||||
|
| `offline_get_items` | `user_id` | Get all offline items |
|
||||||
|
| `offline_search` | `user_id, query` | Search downloaded items |
|
||||||
|
|
||||||
|
## Player Integration
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/player.rs` (modified)
|
||||||
|
|
||||||
|
The player checks for local downloads before streaming:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
|
||||||
|
let local_path = db.and_then(|db_wrapper| {
|
||||||
|
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
|
||||||
|
});
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(jellyfin_id.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url: req.stream_url,
|
||||||
|
jellyfin_item_id: jellyfin_id.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaItem { source, /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Downloads Store
|
||||||
|
|
||||||
|
**Location**: `src/lib/stores/downloads.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadsState {
|
||||||
|
downloads: Record<number, DownloadInfo>;
|
||||||
|
activeCount: number;
|
||||||
|
queuedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloads = createDownloadsStore();
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
downloads.downloadItem(itemId, userId, filePath)
|
||||||
|
downloads.downloadAlbum(albumId, userId)
|
||||||
|
downloads.pause(downloadId)
|
||||||
|
downloads.resume(downloadId)
|
||||||
|
downloads.cancel(downloadId)
|
||||||
|
downloads.delete(downloadId)
|
||||||
|
downloads.refresh(userId, statusFilter)
|
||||||
|
|
||||||
|
// Derived stores
|
||||||
|
export const activeDownloads = derived(downloads, ($d) =>
|
||||||
|
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Handling**:
|
||||||
|
|
||||||
|
The store listens to Tauri events for real-time updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
listen<DownloadEvent>('download-event', (event) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
|
||||||
|
switch (payload.type) {
|
||||||
|
case 'started':
|
||||||
|
// Update status to 'downloading'
|
||||||
|
case 'progress':
|
||||||
|
// Update progress and bytes_downloaded
|
||||||
|
case 'completed':
|
||||||
|
// Update status to 'completed', progress to 1.0
|
||||||
|
case 'failed':
|
||||||
|
// Update status to 'failed', store error message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download UI Components
|
||||||
|
|
||||||
|
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
|
||||||
|
- Multiple states: available, downloading, completed, failed, paused
|
||||||
|
- Circular progress ring during download
|
||||||
|
- Size variants: sm, md, lg
|
||||||
|
- Integrated into TrackList with `showDownload={true}` prop
|
||||||
|
|
||||||
|
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
|
||||||
|
- Individual download list item with progress bar
|
||||||
|
- Action buttons: pause, resume, cancel, delete
|
||||||
|
- Status indicators with color coding
|
||||||
|
|
||||||
|
**Downloads Page** (`src/routes/downloads/+page.svelte`):
|
||||||
|
- Active/Completed tabs
|
||||||
|
- Bulk actions: Pause All, Resume All, Clear Completed
|
||||||
|
- Empty states with helpful instructions
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**downloads table**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
file_path TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
mime_type TEXT,
|
||||||
|
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||||
|
progress REAL DEFAULT 0.0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_queue
|
||||||
|
ON downloads(status, priority DESC, queued_at ASC)
|
||||||
|
WHERE status IN ('pending', 'downloading');
|
||||||
|
```
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Connectivity & Network Architecture
|
||||||
|
|
||||||
|
## HTTP Client with Retry Logic
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/jellyfin/http_client.rs`
|
||||||
|
|
||||||
|
The HTTP client provides automatic retry with exponential backoff for network resilience:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: HttpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpConfig {
|
||||||
|
pub timeout: Duration, // Default: 30s (large library queries can be slow)
|
||||||
|
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 delays: 1s, 2s, 4s (exponential backoff)
|
||||||
|
- Retries on: Network errors, 5xx server errors
|
||||||
|
- No retry on: 4xx client errors, 401/403 authentication errors
|
||||||
|
|
||||||
|
**Error Classification:**
|
||||||
|
```rust
|
||||||
|
pub enum ErrorKind {
|
||||||
|
Network, // Connection failures, timeouts, DNS errors
|
||||||
|
Authentication, // 401/403 responses
|
||||||
|
Server, // 5xx server errors
|
||||||
|
Client, // Other 4xx errors
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connectivity Monitor
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/connectivity/mod.rs`
|
||||||
|
|
||||||
|
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
|
||||||
|
flowchart TB
|
||||||
|
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
|
||||||
|
Monitor --> State{"is_server_reachable?"}
|
||||||
|
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
|
||||||
|
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
|
||||||
|
Probe -->|"reachable again"| Monitor
|
||||||
|
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
|
||||||
|
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:**
|
||||||
|
- **Traffic-driven**: Reachability follows the requests the user actually makes.
|
||||||
|
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
|
||||||
|
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
|
||||||
|
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
|
||||||
|
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
|
||||||
|
|
||||||
|
**Tauri Commands:**
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `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_get_status` | Get current connectivity status |
|
||||||
|
| `connectivity_start_monitoring` | Start the offline recovery probe |
|
||||||
|
| `connectivity_stop_monitoring` | Stop the probe |
|
||||||
|
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
|
||||||
|
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
|
||||||
|
|
||||||
|
**Frontend Integration:**
|
||||||
|
```typescript
|
||||||
|
// 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) => {
|
||||||
|
updateConnectivityState(event.payload.isReachable);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Resilience Architecture
|
||||||
|
|
||||||
|
The connectivity system provides resilience through multiple layers:
|
||||||
|
|
||||||
|
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
||||||
|
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
|
||||||
|
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))
|
||||||
|
|
||||||
|
**Design Principles:**
|
||||||
|
- **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 Fast**: Don't retry 4xx errors (client errors, authentication).
|
||||||
|
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
|
||||||
|
- **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.
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
# Offline Database Design
|
||||||
|
|
||||||
|
## Entity Relationship Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
servers ||--o{ users : "has"
|
||||||
|
servers ||--o{ libraries : "has"
|
||||||
|
libraries ||--o{ items : "contains"
|
||||||
|
items ||--o{ items : "parent_of"
|
||||||
|
items ||--o{ user_data : "has"
|
||||||
|
items ||--o{ downloads : "has"
|
||||||
|
items ||--o{ media_streams : "has"
|
||||||
|
items ||--o{ thumbnails : "has"
|
||||||
|
users ||--o{ user_data : "owns"
|
||||||
|
users ||--o{ downloads : "owns"
|
||||||
|
users ||--o{ sync_queue : "owns"
|
||||||
|
|
||||||
|
servers {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id UK
|
||||||
|
string name
|
||||||
|
string url
|
||||||
|
string version
|
||||||
|
datetime last_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
users {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
string name
|
||||||
|
boolean is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
libraries {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
string name
|
||||||
|
string collection_type
|
||||||
|
string image_tag
|
||||||
|
}
|
||||||
|
|
||||||
|
items {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
int library_id FK
|
||||||
|
int parent_id FK
|
||||||
|
string type
|
||||||
|
string name
|
||||||
|
string sort_name
|
||||||
|
string overview
|
||||||
|
int production_year
|
||||||
|
float community_rating
|
||||||
|
string official_rating
|
||||||
|
int runtime_ticks
|
||||||
|
string primary_image_tag
|
||||||
|
string backdrop_image_tag
|
||||||
|
string album_id
|
||||||
|
string album_name
|
||||||
|
string album_artist
|
||||||
|
json artists
|
||||||
|
json genres
|
||||||
|
int index_number
|
||||||
|
int parent_index_number
|
||||||
|
string premiere_date
|
||||||
|
json metadata_json
|
||||||
|
datetime created_at
|
||||||
|
datetime updated_at
|
||||||
|
datetime last_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int user_id FK
|
||||||
|
int position_ticks
|
||||||
|
int play_count
|
||||||
|
boolean is_favorite
|
||||||
|
boolean played
|
||||||
|
datetime last_played
|
||||||
|
datetime updated_at
|
||||||
|
datetime synced_at
|
||||||
|
}
|
||||||
|
|
||||||
|
downloads {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int user_id FK
|
||||||
|
string file_path
|
||||||
|
int file_size
|
||||||
|
string status
|
||||||
|
float progress
|
||||||
|
int priority
|
||||||
|
string error_message
|
||||||
|
datetime created_at
|
||||||
|
datetime completed_at
|
||||||
|
}
|
||||||
|
|
||||||
|
media_streams {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int stream_index
|
||||||
|
string type
|
||||||
|
string codec
|
||||||
|
string language
|
||||||
|
string display_title
|
||||||
|
boolean is_default
|
||||||
|
boolean is_forced
|
||||||
|
boolean is_external
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_queue {
|
||||||
|
int id PK
|
||||||
|
int user_id FK
|
||||||
|
string operation
|
||||||
|
string entity_type
|
||||||
|
string entity_id
|
||||||
|
json payload
|
||||||
|
datetime created_at
|
||||||
|
int attempts
|
||||||
|
datetime last_attempt
|
||||||
|
string status
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnails {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
string image_type
|
||||||
|
string image_tag
|
||||||
|
string file_path
|
||||||
|
int width
|
||||||
|
int height
|
||||||
|
datetime cached_at
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Definitions
|
||||||
|
|
||||||
|
### servers
|
||||||
|
Stores connected Jellyfin server information.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
last_sync DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### users
|
||||||
|
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### libraries
|
||||||
|
Stores library/collection metadata.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE libraries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
collection_type TEXT,
|
||||||
|
image_tag TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
last_sync DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_libraries_server ON libraries(server_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### items
|
||||||
|
Main table for all media items (movies, episodes, albums, songs, etc.).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
|
||||||
|
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Basic metadata
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_name TEXT,
|
||||||
|
overview TEXT,
|
||||||
|
|
||||||
|
-- Media info
|
||||||
|
production_year INTEGER,
|
||||||
|
community_rating REAL,
|
||||||
|
official_rating TEXT,
|
||||||
|
runtime_ticks INTEGER,
|
||||||
|
|
||||||
|
-- Images
|
||||||
|
primary_image_tag TEXT,
|
||||||
|
backdrop_image_tag TEXT,
|
||||||
|
|
||||||
|
-- Audio-specific
|
||||||
|
album_id TEXT,
|
||||||
|
album_name TEXT,
|
||||||
|
album_artist TEXT,
|
||||||
|
artists TEXT, -- JSON array
|
||||||
|
|
||||||
|
-- Series/Season-specific
|
||||||
|
index_number INTEGER,
|
||||||
|
parent_index_number INTEGER,
|
||||||
|
series_id TEXT,
|
||||||
|
series_name TEXT,
|
||||||
|
season_id TEXT,
|
||||||
|
|
||||||
|
-- Additional
|
||||||
|
genres TEXT, -- JSON array
|
||||||
|
premiere_date TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
|
||||||
|
-- Sync tracking
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_sync DATETIME,
|
||||||
|
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance indexes
|
||||||
|
CREATE INDEX idx_items_server ON items(server_id);
|
||||||
|
CREATE INDEX idx_items_library ON items(library_id);
|
||||||
|
CREATE INDEX idx_items_parent ON items(parent_id);
|
||||||
|
CREATE INDEX idx_items_type ON items(type);
|
||||||
|
CREATE INDEX idx_items_album ON items(album_id);
|
||||||
|
CREATE INDEX idx_items_series ON items(series_id);
|
||||||
|
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
|
||||||
|
|
||||||
|
-- Full-text search
|
||||||
|
CREATE VIRTUAL TABLE items_fts USING fts5(
|
||||||
|
name,
|
||||||
|
overview,
|
||||||
|
artists,
|
||||||
|
album_name,
|
||||||
|
album_artist,
|
||||||
|
content='items',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Triggers to keep FTS in sync
|
||||||
|
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
|
||||||
|
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
|
||||||
|
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
|
||||||
|
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
||||||
|
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
||||||
|
END;
|
||||||
|
```
|
||||||
|
|
||||||
|
### media_streams
|
||||||
|
Stores subtitle and audio track information for items.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE media_streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
stream_index INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
codec TEXT,
|
||||||
|
language TEXT,
|
||||||
|
display_title TEXT,
|
||||||
|
is_default BOOLEAN DEFAULT 0,
|
||||||
|
is_forced BOOLEAN DEFAULT 0,
|
||||||
|
is_external BOOLEAN DEFAULT 0,
|
||||||
|
path TEXT,
|
||||||
|
UNIQUE(item_id, stream_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_data
|
||||||
|
Stores per-user data for items (favorites, progress, play count).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Playback state
|
||||||
|
position_ticks INTEGER DEFAULT 0,
|
||||||
|
play_count INTEGER DEFAULT 0,
|
||||||
|
played BOOLEAN DEFAULT 0,
|
||||||
|
last_played DATETIME,
|
||||||
|
|
||||||
|
-- User preferences
|
||||||
|
is_favorite BOOLEAN DEFAULT 0,
|
||||||
|
user_rating REAL,
|
||||||
|
|
||||||
|
-- Sync tracking
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced_at DATETIME,
|
||||||
|
needs_sync BOOLEAN DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_data_item ON user_data(item_id);
|
||||||
|
CREATE INDEX idx_user_data_user ON user_data(user_id);
|
||||||
|
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
|
||||||
|
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
|
||||||
|
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
|
||||||
|
WHERE position_ticks > 0 AND played = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### downloads
|
||||||
|
Tracks downloaded media files.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
file_path TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
file_hash TEXT,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
transcode_profile TEXT,
|
||||||
|
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
expires_at DATETIME,
|
||||||
|
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_status ON downloads(status);
|
||||||
|
CREATE INDEX idx_downloads_user ON downloads(user_id);
|
||||||
|
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
|
||||||
|
WHERE status IN ('pending', 'downloading');
|
||||||
|
```
|
||||||
|
|
||||||
|
### sync_queue
|
||||||
|
Stores mutations to sync back to server when online.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sync_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
max_attempts INTEGER DEFAULT 5,
|
||||||
|
last_attempt DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### thumbnails
|
||||||
|
Caches downloaded artwork.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
image_type TEXT NOT NULL,
|
||||||
|
image_tag TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
file_size INTEGER,
|
||||||
|
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(item_id, image_type, width)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
|
||||||
|
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### playlists (for local/synced playlists)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_local_only BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced_at DATETIME,
|
||||||
|
needs_sync BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(playlist_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Queries
|
||||||
|
|
||||||
|
### Get items for offline library browsing
|
||||||
|
```sql
|
||||||
|
-- Get all albums in a music library
|
||||||
|
SELECT * FROM items
|
||||||
|
WHERE library_id = ? AND type = 'MusicAlbum'
|
||||||
|
ORDER BY sort_name;
|
||||||
|
|
||||||
|
-- Get tracks for an album
|
||||||
|
SELECT * FROM items
|
||||||
|
WHERE album_id = ? AND type = 'Audio'
|
||||||
|
ORDER BY parent_index_number, index_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resume / Continue Watching
|
||||||
|
```sql
|
||||||
|
SELECT i.*, ud.position_ticks, ud.last_played
|
||||||
|
FROM items i
|
||||||
|
JOIN user_data ud ON ud.item_id = i.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
AND ud.position_ticks > 0
|
||||||
|
AND ud.played = 0
|
||||||
|
ORDER BY ud.last_played DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline search
|
||||||
|
```sql
|
||||||
|
SELECT i.* FROM items i
|
||||||
|
JOIN items_fts fts ON fts.rowid = i.id
|
||||||
|
WHERE items_fts MATCH ?
|
||||||
|
ORDER BY rank;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download queue management
|
||||||
|
```sql
|
||||||
|
-- Get next item to download
|
||||||
|
SELECT d.*, i.name, i.type
|
||||||
|
FROM downloads d
|
||||||
|
JOIN items i ON i.id = d.item_id
|
||||||
|
WHERE d.status = 'pending'
|
||||||
|
ORDER BY d.priority DESC, d.created_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Get download progress for UI
|
||||||
|
SELECT
|
||||||
|
d.status,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(d.file_size) as total_size,
|
||||||
|
SUM(d.bytes_downloaded) as downloaded
|
||||||
|
FROM downloads d
|
||||||
|
WHERE d.user_id = ?
|
||||||
|
GROUP BY d.status;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync queue processing
|
||||||
|
```sql
|
||||||
|
-- Get pending sync operations (oldest first)
|
||||||
|
SELECT * FROM sync_queue
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND attempts < max_attempts
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- Mark operation complete
|
||||||
|
UPDATE sync_queue
|
||||||
|
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Online Mode
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OnlineMode["Online Mode"]
|
||||||
|
JellyfinServer["Jellyfin Server"]
|
||||||
|
OnlineRepo["OnlineRepo"]
|
||||||
|
SQLite["SQLite"]
|
||||||
|
HybridRepo["HybridRepository"]
|
||||||
|
UI["UI / Stores"]
|
||||||
|
|
||||||
|
JellyfinServer -->|"API Response"| OnlineRepo
|
||||||
|
OnlineRepo -->|"Cache"| SQLite
|
||||||
|
SQLite -->|"Sync"| JellyfinServer
|
||||||
|
OnlineRepo -->|"Response"| HybridRepo
|
||||||
|
SQLite -->|"Fallback"| HybridRepo
|
||||||
|
HybridRepo --> UI
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline Mode
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OfflineMode["Offline Mode"]
|
||||||
|
OfflineRepo["OfflineRepo"]
|
||||||
|
SQLite2["SQLite"]
|
||||||
|
SyncQueue["sync_queue<br/>(Queued for later)"]
|
||||||
|
HybridRepo2["HybridRepository"]
|
||||||
|
UI2["UI / Stores"]
|
||||||
|
|
||||||
|
OfflineRepo <-->|"Query"| SQLite2
|
||||||
|
SQLite2 -->|"Mutations"| SyncQueue
|
||||||
|
OfflineRepo --> HybridRepo2
|
||||||
|
HybridRepo2 --> UI2
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync on Reconnect
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
NetworkRestored["Network restored"]
|
||||||
|
SyncService["SyncService"]
|
||||||
|
SyncQueue2["sync_queue"]
|
||||||
|
JellyfinAPI["Jellyfin API"]
|
||||||
|
MarkSynced["Mark synced"]
|
||||||
|
|
||||||
|
NetworkRestored --> SyncService
|
||||||
|
SyncService -->|"Read"| SyncQueue2
|
||||||
|
SyncQueue2 -->|"Send"| JellyfinAPI
|
||||||
|
JellyfinAPI -->|"Success"| MarkSynced
|
||||||
|
MarkSynced --> SyncService
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Estimates
|
||||||
|
|
||||||
|
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|
||||||
|
|--------------|---------------|----------------|------------|
|
||||||
|
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
|
||||||
|
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
|
||||||
|
| Movie | ~5 KB | ~200 KB | 1-8 GB |
|
||||||
|
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
||||||
|
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
||||||
|
|
||||||
|
## Rust Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/storage/
|
||||||
|
├── mod.rs # Module exports, Database struct
|
||||||
|
├── schema.rs # Table definitions, migrations
|
||||||
|
├── models.rs # Rust structs matching tables
|
||||||
|
├── queries/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── items.rs # Item CRUD operations
|
||||||
|
│ ├── user_data.rs # User data operations
|
||||||
|
│ ├── downloads.rs # Download queue operations
|
||||||
|
│ └── sync.rs # Sync queue operations
|
||||||
|
└── sync/
|
||||||
|
├── mod.rs # SyncService
|
||||||
|
├── manager.rs # Background sync manager
|
||||||
|
└── operations.rs # Individual sync operation handlers
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Authentication Token Storage
|
||||||
|
|
||||||
|
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
LoginSuccess["Login Success"]
|
||||||
|
KeyringCheck{"System Keyring<br/>Available?"}
|
||||||
|
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
|
||||||
|
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
|
||||||
|
|
||||||
|
LoginSuccess --> KeyringCheck
|
||||||
|
KeyringCheck -->|"Yes"| OSCredential
|
||||||
|
KeyringCheck -->|"No"| EncryptedFallback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Format:**
|
||||||
|
```
|
||||||
|
jellytau::{server_id}::{user_id}::access_token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Tokens in SQLite would be readable if the database file is accessed
|
||||||
|
- System keyrings provide OS-level encryption and access control
|
||||||
|
- Fallback ensures functionality on minimal systems without a keyring daemon
|
||||||
|
|
||||||
|
## Secure Storage Module
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/secure_storage/` (planned)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait SecureStorage: Send + Sync {
|
||||||
|
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
|
||||||
|
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
|
||||||
|
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform implementations
|
||||||
|
pub struct KeyringStorage; // Uses keyring crate
|
||||||
|
pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Security
|
||||||
|
|
||||||
|
| Aspect | Implementation |
|
||||||
|
|--------|----------------|
|
||||||
|
| Transport | HTTPS required for all Jellyfin API calls |
|
||||||
|
| Certificate Validation | System CA store (configurable for self-signed) |
|
||||||
|
| Token Transmission | Bearer token in `Authorization` header only |
|
||||||
|
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
||||||
|
|
||||||
|
## Local Data Protection
|
||||||
|
|
||||||
|
| Data Type | Protection |
|
||||||
|
|-----------|------------|
|
||||||
|
| Access Tokens | System keyring or encrypted file |
|
||||||
|
| Database (SQLite) | Plaintext (metadata only, no secrets) |
|
||||||
|
| Downloaded Media | Filesystem permissions only |
|
||||||
|
| Cached Thumbnails | Filesystem permissions only |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
|
||||||
|
2. **Token Isolation**: Each user/server combination has a separate token entry
|
||||||
|
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
||||||
|
4. **No Token Logging**: Tokens are never written to logs or debug output
|
||||||
|
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# JellyTau Software Architecture
|
||||||
|
|
||||||
|
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
|
**Last Updated:** 2026-06-20
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
|
||||||
|
|
||||||
|
### Architecture Principles
|
||||||
|
|
||||||
|
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
||||||
|
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["Svelte Frontend"]
|
||||||
|
subgraph Stores["Stores (Thin Wrappers)"]
|
||||||
|
auth["auth"]
|
||||||
|
player["player"]
|
||||||
|
queue["queue"]
|
||||||
|
library["library"]
|
||||||
|
connectivity["connectivity"]
|
||||||
|
playbackMode["playbackMode"]
|
||||||
|
end
|
||||||
|
subgraph Components
|
||||||
|
playerComp["player/"]
|
||||||
|
libraryComp["library/"]
|
||||||
|
Search["Search"]
|
||||||
|
end
|
||||||
|
subgraph Routes
|
||||||
|
routeLibrary["/library"]
|
||||||
|
routePlayer["/player"]
|
||||||
|
routeRoot["/"]
|
||||||
|
end
|
||||||
|
subgraph API["API Layer (Thin Client)"]
|
||||||
|
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
|
||||||
|
JellyfinClient["JellyfinClient<br/>(Helper)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Frontend -->|"Tauri IPC (invoke)"| Backend
|
||||||
|
|
||||||
|
subgraph Backend["Rust Backend (Business Logic)"]
|
||||||
|
subgraph Commands["Tauri Commands (90+)"]
|
||||||
|
PlayerCmds["player.rs"]
|
||||||
|
RepoCmds["repository.rs (27)"]
|
||||||
|
PlaybackModeCmds["playback_mode.rs (5)"]
|
||||||
|
StorageCmds["storage.rs"]
|
||||||
|
ConnectivityCmds["connectivity.rs (7)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core["Core Modules"]
|
||||||
|
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
|
||||||
|
|
||||||
|
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
|
||||||
|
|
||||||
|
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
|
||||||
|
|
||||||
|
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
|
||||||
|
|
||||||
|
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
|
||||||
|
|
||||||
|
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage["Storage Layer"]
|
||||||
|
DatabaseService["DatabaseService<br/>(Async trait)"]
|
||||||
|
SQLite["SQLite Database<br/>(13 tables)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Commands --> Core
|
||||||
|
Core --> Storage
|
||||||
|
Repository --> HttpClient
|
||||||
|
Repository --> DatabaseService
|
||||||
|
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
|
||||||
|
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
|
||||||
|
|
||||||
|
Each major subsystem is documented in its own file in this directory:
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|----------|----------|
|
||||||
|
| [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](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](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
||||||
|
| [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](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
||||||
|
| [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](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
||||||
|
| [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](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/
|
||||||
|
├── lib.rs # Tauri app setup, state initialization
|
||||||
|
├── commands/ # Tauri command handlers (90+ commands)
|
||||||
|
│ ├── mod.rs # Command exports
|
||||||
|
│ ├── player.rs # 16 player commands
|
||||||
|
│ ├── repository.rs # 27 repository commands
|
||||||
|
│ ├── playlist.rs # 7 playlist commands
|
||||||
|
│ ├── playback_mode.rs # 5 playback mode commands
|
||||||
|
│ ├── connectivity.rs # 7 connectivity commands
|
||||||
|
│ ├── storage.rs # Storage & database commands
|
||||||
|
│ ├── download.rs # 7 download commands
|
||||||
|
│ ├── offline.rs # 3 offline commands
|
||||||
|
│ └── sync.rs # Sync queue commands
|
||||||
|
├── repository/ # Repository pattern implementation
|
||||||
|
│ ├── mod.rs # MediaRepository trait, handle management
|
||||||
|
│ ├── types.rs # RepoError, Library, MediaItem, etc.
|
||||||
|
│ ├── hybrid.rs # HybridRepository with cache-first racing
|
||||||
|
│ ├── online.rs # OnlineRepository (HTTP API)
|
||||||
|
│ └── offline.rs # OfflineRepository (SQLite queries)
|
||||||
|
├── playback_mode/ # Playback mode manager
|
||||||
|
│ └── mod.rs # PlaybackMode enum, transfer logic
|
||||||
|
├── connectivity/ # Connectivity monitoring
|
||||||
|
│ └── mod.rs # ConnectivityMonitor, adaptive polling
|
||||||
|
├── jellyfin/ # Jellyfin API client
|
||||||
|
│ ├── mod.rs # Module exports
|
||||||
|
│ ├── http_client.rs # HTTP client with retry logic
|
||||||
|
│ └── client.rs # JellyfinClient for API calls
|
||||||
|
├── storage/ # Database layer
|
||||||
|
│ ├── mod.rs # Database struct, migrations
|
||||||
|
│ ├── db_service.rs # DatabaseService trait (async wrapper)
|
||||||
|
│ ├── schema.rs # Table definitions
|
||||||
|
│ └── queries/ # Query modules
|
||||||
|
├── download/ # Download manager module
|
||||||
|
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
|
||||||
|
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
|
||||||
|
│ ├── events.rs # DownloadEvent enum
|
||||||
|
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
|
||||||
|
└── player/ # Player subsystem
|
||||||
|
├── mod.rs # PlayerController
|
||||||
|
├── session.rs # MediaSessionManager, MediaSessionType
|
||||||
|
├── state.rs # PlayerState, PlayerEvent
|
||||||
|
├── media.rs # MediaItem, MediaSource, MediaType
|
||||||
|
├── queue.rs # QueueManager, RepeatMode
|
||||||
|
├── backend.rs # PlayerBackend trait, NullBackend
|
||||||
|
├── events.rs # PlayerStatusEvent, TauriEventEmitter
|
||||||
|
├── mpv/ # Linux MPV backend
|
||||||
|
│ ├── mod.rs # MpvBackend implementation
|
||||||
|
│ └── event_loop.rs # Dedicated thread for MPV operations
|
||||||
|
└── android/ # Android ExoPlayer backend
|
||||||
|
└── mod.rs # ExoPlayerBackend + JNI bindings
|
||||||
|
|
||||||
|
src/lib/
|
||||||
|
├── api/ # Thin API layer (~200 lines total)
|
||||||
|
│ ├── types.ts # TypeScript type definitions
|
||||||
|
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
||||||
|
│ ├── client.ts # JellyfinClient (helper for streaming)
|
||||||
|
│ └── sessions.ts # SessionsApi (remote session control)
|
||||||
|
├── services/
|
||||||
|
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||||
|
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||||
|
├── stores/ # Thin reactive wrappers over Rust commands
|
||||||
|
│ ├── index.ts # Re-exports
|
||||||
|
│ ├── auth.ts # Auth store (calls Rust commands)
|
||||||
|
│ ├── player.ts # Player store
|
||||||
|
│ ├── queue.ts # Queue store
|
||||||
|
│ ├── library.ts # Library store
|
||||||
|
│ ├── playbackMode.ts # Playback mode store (~150 lines)
|
||||||
|
│ ├── connectivity.ts # Connectivity store (~250 lines)
|
||||||
|
│ └── downloads.ts # Downloads store with event listeners
|
||||||
|
└── components/
|
||||||
|
├── Search.svelte
|
||||||
|
├── player/ # Player UI components
|
||||||
|
├── playlist/ # Playlist modals (Create, AddTo)
|
||||||
|
├── sessions/ # Remote session control UI
|
||||||
|
├── downloads/ # Download UI components
|
||||||
|
└── library/ # Library UI components + PlaylistDetailView
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Architecture Changes
|
||||||
|
|
||||||
|
**What moved to Rust (~3,500 lines of business logic):**
|
||||||
|
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
||||||
|
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
|
||||||
|
4. **Database Service** - Async wrapper preventing UI freezing
|
||||||
|
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
||||||
|
|
||||||
|
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
|
||||||
|
- Components + routes (~14.6k lines) — UI and presentation
|
||||||
|
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
|
||||||
|
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
|
||||||
|
|
||||||
|
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
||||||
|
|
||||||
|
**Total Commands:** 90+ Tauri commands across 14 command modules
|
||||||
Vendored
+2
-2
@@ -13,7 +13,7 @@ The setup includes:
|
|||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
**For CI/CD (Gitea Actions)**:
|
**For CI/CD (Gitea Actions)**:
|
||||||
1. Build and push builder image (see [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md))
|
1. Build and push builder image (see [build-builder-image.md](build-builder-image.md))
|
||||||
2. Push to master branch - workflow runs automatically
|
2. Push to master branch - workflow runs automatically
|
||||||
3. Check Actions tab for results and APK artifacts
|
3. Check Actions tab for results and APK artifacts
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-build
|
|||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
See [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md) for detailed instructions.
|
See [build-builder-image.md](build-builder-image.md) for detailed instructions.
|
||||||
|
|
||||||
### Setting Up Gitea Act
|
### Setting Up Gitea Act
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Release Checklist
|
||||||
|
|
||||||
|
Quick reference for creating a JellyTau release.
|
||||||
|
|
||||||
|
## Pre-Release (1-2 days before)
|
||||||
|
|
||||||
|
- [ ] Code is on `master`/`main` branch
|
||||||
|
- [ ] All feature branches are merged and tested
|
||||||
|
- [ ] No failing tests locally: `bun run test` and `bun run test:rust`
|
||||||
|
- [ ] Requirement traceability check passes: `bun run traces:json`
|
||||||
|
- [ ] Type checking passes: `bun run check`
|
||||||
|
|
||||||
|
## Update Version (Day before)
|
||||||
|
|
||||||
|
- [ ] Decide on version number (semantic versioning)
|
||||||
|
- Example: `v1.2.0` (major.minor.patch)
|
||||||
|
- Example: `v1.0.0-rc1` (release candidate)
|
||||||
|
- Example: `v1.0.0-beta` (beta)
|
||||||
|
|
||||||
|
- [ ] Update version in files:
|
||||||
|
```bash
|
||||||
|
# Check these files for version numbers
|
||||||
|
cat package.json | grep version
|
||||||
|
cat src-tauri/tauri.conf.json | grep version
|
||||||
|
cat src-tauri/Cargo.toml | grep version
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Update `CHANGELOG.md`:
|
||||||
|
- [ ] Add section for new version
|
||||||
|
- [ ] List all features added
|
||||||
|
- [ ] List all bugs fixed
|
||||||
|
- [ ] List breaking changes (if any)
|
||||||
|
- [ ] Add upgrade instructions (if needed)
|
||||||
|
- [ ] Format: Markdown with clear sections
|
||||||
|
|
||||||
|
- [ ] Update `README.md`:
|
||||||
|
- [ ] Update any version references
|
||||||
|
- [ ] Update feature list if applicable
|
||||||
|
- [ ] Update requirements if changed
|
||||||
|
|
||||||
|
- [ ] Commit changes:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Bump version to v1.2.0"
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Check Before Release
|
||||||
|
|
||||||
|
- [ ] Run full test suite:
|
||||||
|
```bash
|
||||||
|
bun run test # Frontend tests
|
||||||
|
bun run test:rust # Rust tests
|
||||||
|
bun run check # Type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Build locally (optional but recommended):
|
||||||
|
```bash
|
||||||
|
# Test Linux build
|
||||||
|
bun run tauri build
|
||||||
|
|
||||||
|
# Test Android build
|
||||||
|
bun run tauri android build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] No uncommitted changes:
|
||||||
|
```bash
|
||||||
|
git status # Should show clean working directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release (Tag & Push)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create annotated tag with release notes
|
||||||
|
git tag -a v1.2.0 -m "Release version 1.2.0
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- New feature 1
|
||||||
|
- New feature 2
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
- Fixed bug 1
|
||||||
|
- Fixed bug 2
|
||||||
|
|
||||||
|
Improvements:
|
||||||
|
- Performance improvement 1
|
||||||
|
- UI improvement 1
|
||||||
|
|
||||||
|
Breaking Changes:
|
||||||
|
- None (or list if applicable)
|
||||||
|
|
||||||
|
Migration:
|
||||||
|
- No action required (or include steps if applicable)"
|
||||||
|
|
||||||
|
# 2. Push tag to trigger workflow
|
||||||
|
git push origin v1.2.0
|
||||||
|
|
||||||
|
# 3. Monitor in Gitea Actions
|
||||||
|
# Go to Actions tab and watch the workflow run
|
||||||
|
```
|
||||||
|
|
||||||
|
## During Release (While Workflow Runs)
|
||||||
|
|
||||||
|
- [ ] Watch workflow progress in Gitea Actions
|
||||||
|
- [ ] Monitor for test failures
|
||||||
|
- [ ] Monitor for build failures
|
||||||
|
- [ ] Check build logs if any step fails
|
||||||
|
|
||||||
|
## After Release (Workflow Complete)
|
||||||
|
|
||||||
|
- [ ] Download artifacts from release page:
|
||||||
|
- [ ] `jellytau_*.AppImage` (Linux)
|
||||||
|
- [ ] `jellytau_*.deb` (Linux)
|
||||||
|
- [ ] `jellytau-release.apk` (Android)
|
||||||
|
- [ ] `jellytau-release.aab` (Android)
|
||||||
|
|
||||||
|
- [ ] Basic testing of artifacts:
|
||||||
|
- [ ] Linux AppImage runs
|
||||||
|
- [ ] Linux DEB installs and runs
|
||||||
|
- [ ] Android APK installs (via `adb` or sideload)
|
||||||
|
|
||||||
|
- [ ] Verify release page:
|
||||||
|
- [ ] Title is correct: "JellyTau vX.Y.Z"
|
||||||
|
- [ ] Release notes are formatted correctly
|
||||||
|
- [ ] All artifacts are uploaded
|
||||||
|
- [ ] Release type is correct (prerelease vs release)
|
||||||
|
|
||||||
|
- [ ] Announce release:
|
||||||
|
- [ ] Post to relevant channels/communities
|
||||||
|
- [ ] Update website/docs
|
||||||
|
- [ ] Tag contributors if applicable
|
||||||
|
|
||||||
|
## Rollback (If Issues Found)
|
||||||
|
|
||||||
|
If critical issues are found after release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Delete tag locally and remotely
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin :refs/tags/v1.2.0
|
||||||
|
|
||||||
|
# Option 2: Mark as prerelease in release page
|
||||||
|
# Then plan immediate patch release (v1.2.1)
|
||||||
|
|
||||||
|
# Option 3: Create hotfix branch and release v1.2.1
|
||||||
|
git checkout -b hotfix/v1.2.1
|
||||||
|
# Fix issues
|
||||||
|
git commit -m "Fix critical issue"
|
||||||
|
git tag v1.2.1
|
||||||
|
git push origin hotfix/v1.2.1 v1.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Examples
|
||||||
|
|
||||||
|
### Major Release
|
||||||
|
```
|
||||||
|
v2.0.0 - Major version bump
|
||||||
|
- Significant new features
|
||||||
|
- Breaking API changes
|
||||||
|
- Major UI redesign
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minor Release
|
||||||
|
```
|
||||||
|
v1.2.0 - Feature release
|
||||||
|
- New features
|
||||||
|
- Backward compatible
|
||||||
|
- Bug fixes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
```
|
||||||
|
v1.1.1 - Bug fix/patch
|
||||||
|
- Bug fixes only
|
||||||
|
- No new features
|
||||||
|
- Backward compatible
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-releases
|
||||||
|
```
|
||||||
|
v1.2.0-alpha - Early development
|
||||||
|
v1.2.0-beta - Late development, feature complete
|
||||||
|
v1.2.0-rc1 - Release candidate, minimal fixes only
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
Key files for versioning:
|
||||||
|
- `package.json` - Frontend version
|
||||||
|
- `src-tauri/tauri.conf.json` - Tauri config version
|
||||||
|
- `src-tauri/Cargo.toml` - Rust version
|
||||||
|
- `CHANGELOG.md` - Release history
|
||||||
|
- `README.md` - Project documentation
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests Fail Before Release
|
||||||
|
1. Don't push tag yet
|
||||||
|
2. Fix failing tests locally
|
||||||
|
3. Push fixes to master
|
||||||
|
4. Re-run test suite
|
||||||
|
5. Then tag and push
|
||||||
|
|
||||||
|
### Build Fails in CI
|
||||||
|
1. Check detailed logs in Gitea Actions
|
||||||
|
2. Fix issue locally
|
||||||
|
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
|
||||||
|
4. Push fix to master
|
||||||
|
5. Create new tag with fix
|
||||||
|
|
||||||
|
### Release Already Exists
|
||||||
|
1. If workflow runs twice, artifacts may conflict
|
||||||
|
2. Check release page
|
||||||
|
3. If duplicates exist, delete and re-release
|
||||||
|
|
||||||
|
### Artifacts Missing
|
||||||
|
1. Check build logs for errors
|
||||||
|
2. Verify platform-specific dependencies
|
||||||
|
3. Delete tag and retry after fixes
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- Tests: ~5-10 minutes
|
||||||
|
- Linux build: ~10-15 minutes
|
||||||
|
- Android build: ~15-20 minutes
|
||||||
|
- Total release time: ~30-45 minutes
|
||||||
|
|
||||||
|
First build takes longer (cache warming). Subsequent releases are faster due to caching.
|
||||||
|
|
||||||
|
## Template: Release Notes
|
||||||
|
|
||||||
|
```
|
||||||
|
## 🎉 JellyTau vX.Y.Z
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- New feature 1
|
||||||
|
- New feature 2
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
- Fixed issue #123
|
||||||
|
- Fixed issue #456
|
||||||
|
|
||||||
|
### 🚀 Performance
|
||||||
|
- Improvement 1
|
||||||
|
- Improvement 2
|
||||||
|
|
||||||
|
### 📱 Downloads
|
||||||
|
- [Linux AppImage](#) - Run on any Linux
|
||||||
|
- [Linux DEB](#) - Install on Ubuntu/Debian
|
||||||
|
- [Android APK](#) - Install on Android devices
|
||||||
|
- [Android AAB](#) - For Google Play Store
|
||||||
|
|
||||||
|
### 📋 Requirements
|
||||||
|
**Linux:** 64-bit, GLIBC 2.29+
|
||||||
|
**Android:** 8.0+
|
||||||
|
|
||||||
|
### 🔗 Links
|
||||||
|
- [Changelog](../../CHANGELOG.md)
|
||||||
|
- [Issues](../../issues)
|
||||||
|
- [Discussion](../../discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
Built with Tauri, SvelteKit, and Rust 🦀
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View existing tags
|
||||||
|
git tag -l
|
||||||
|
|
||||||
|
# Create release locally (dry run)
|
||||||
|
git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
|
||||||
|
|
||||||
|
# List commits since last tag
|
||||||
|
git log v1.1.0..HEAD --oneline
|
||||||
|
|
||||||
|
# Show tag details
|
||||||
|
git show v1.2.0
|
||||||
|
|
||||||
|
# Rename tag (if needed)
|
||||||
|
git tag v1.2.0_old v1.2.0
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin v1.2.0_old v1.2.0
|
||||||
|
|
||||||
|
# Delete tag locally and remotely
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin :refs/tags/v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- ✅ Always test locally before release
|
||||||
|
- ✅ Use semantic versioning consistently
|
||||||
|
- ✅ Document changes in CHANGELOG
|
||||||
|
- ✅ Wait for full workflow completion
|
||||||
|
- ✅ Test release artifacts before announcing
|
||||||
|
|
||||||
|
**Remember:** A good release is a tested release! 🚀
|
||||||
@@ -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
|
||||||
@@ -138,7 +138,7 @@ The workflow **warns** (but doesn't block) if:
|
|||||||
3. Download **traceability-reports** artifact
|
3. Download **traceability-reports** artifact
|
||||||
4. View:
|
4. View:
|
||||||
- `traces-report.json` - Raw trace data
|
- `traces-report.json` - Raw trace data
|
||||||
- `docs/TRACEABILITY.md` - Formatted report
|
- `docs/traceability.md` - Formatted report
|
||||||
|
|
||||||
### Locally
|
### Locally
|
||||||
```bash
|
```bash
|
||||||
@@ -147,7 +147,7 @@ bun run traces:json | jq '.byType'
|
|||||||
|
|
||||||
# Generate full report
|
# Generate full report
|
||||||
bun run traces:markdown
|
bun run traces:markdown
|
||||||
cat docs/TRACEABILITY.md
|
cat docs/traceability.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coverage Goals
|
## Coverage Goals
|
||||||
@@ -272,7 +272,7 @@ fi
|
|||||||
|
|
||||||
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
||||||
- [Requirements Specification](../README.md#requirements-specification)
|
- [Requirements Specification](../README.md#requirements-specification)
|
||||||
- [Traceability Matrix](./TRACEABILITY.md)
|
- [Traceability Matrix](./traceability.md)
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
# TRACES Quick Reference Guide
|
||||||
|
|
||||||
|
## What are TRACES?
|
||||||
|
|
||||||
|
TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.
|
||||||
|
|
||||||
|
Format: `// TRACES: UR-001, UR-002 | DR-003`
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026 | DR-029
|
||||||
|
export function handlePlayback() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume playback from saved position
|
||||||
|
* TRACES: UR-019 | DR-022
|
||||||
|
*/
|
||||||
|
export async function resumePlayback(itemId: string) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svelte
|
||||||
|
```svelte
|
||||||
|
<!-- TRACES: UR-007, UR-008 | DR-007 -->
|
||||||
|
<script>
|
||||||
|
export let items = [];
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
```rust
|
||||||
|
/// TRACES: UR-005 | DR-001
|
||||||
|
pub enum PlayerState { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_next() {
|
||||||
|
// TRACES: UR-005 | DR-005 | UT-003
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirement Types
|
||||||
|
|
||||||
|
| Type | Meaning | Example |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **UR** | User Requirement | UR-005: Control media playback |
|
||||||
|
| **IR** | Integration Requirement | IR-003: LibMPV integration |
|
||||||
|
| **DR** | Development Requirement | DR-001: Player state machine |
|
||||||
|
| **JA** | Jellyfin API Requirement | JA-007: Get playback info |
|
||||||
|
| **UT** | Unit Test | UT-001: Player state transitions |
|
||||||
|
| **IT** | Integration Test | IT-003: Audio playback via libmpv |
|
||||||
|
|
||||||
|
## Where to Find Requirements
|
||||||
|
|
||||||
|
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
||||||
|
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
||||||
|
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
||||||
|
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
||||||
|
|
||||||
|
## How to Add TRACES
|
||||||
|
|
||||||
|
### Step 1: Find the Requirement
|
||||||
|
Look up the requirement in README.md or the traceability matrix.
|
||||||
|
|
||||||
|
Example: `UR-005: Control media playback (pause, play, skip, scrub)`
|
||||||
|
|
||||||
|
### Step 2: Add Comment
|
||||||
|
Add TRACES comment at the top of the function/type/module:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005
|
||||||
|
export async function playMedia(itemId: string) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Run Extraction
|
||||||
|
Verify the trace is captured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Single Requirement
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005
|
||||||
|
function handlePlay() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Requirements, Same Type
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026, UR-019
|
||||||
|
function handlePlaybackState() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Types
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026 | DR-029
|
||||||
|
function autoplayNextEpisode() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005 | UT-001
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_transition() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modules/Files
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Player event handling
|
||||||
|
* TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Check Your Changes
|
||||||
|
```bash
|
||||||
|
# View current coverage
|
||||||
|
bun run traces:json | jq '.byType'
|
||||||
|
|
||||||
|
# Generate full report
|
||||||
|
bun run traces:markdown
|
||||||
|
|
||||||
|
# Check specific requirement
|
||||||
|
bun run traces:json | jq '.requirements."UR-005"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
1. Ensure all new code has TRACES
|
||||||
|
2. Format is correct: `// TRACES: ...`
|
||||||
|
3. Requirements exist in README.md
|
||||||
|
4. No typos in requirement IDs
|
||||||
|
|
||||||
|
## CI/CD Validation
|
||||||
|
|
||||||
|
The workflow automatically checks:
|
||||||
|
- ✅ Coverage stays >= 50%
|
||||||
|
- ✅ New files have TRACES
|
||||||
|
- ✅ JSON format is valid
|
||||||
|
- ✅ Reports are generated
|
||||||
|
|
||||||
|
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
||||||
|
|
||||||
|
## Tips & Tricks
|
||||||
|
|
||||||
|
### Find Related Code
|
||||||
|
```bash
|
||||||
|
# Find all code tracing to UR-005
|
||||||
|
bun run traces:json | jq '.requirements."UR-005"'
|
||||||
|
|
||||||
|
# List all tests
|
||||||
|
bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Your Editor
|
||||||
|
|
||||||
|
**VS Code:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"editor.wordBasedSuggestions": false,
|
||||||
|
"editor.suggest.custom": [
|
||||||
|
{
|
||||||
|
"name": "TRACES Format",
|
||||||
|
"insertText": "// TRACES: $1",
|
||||||
|
"insertTextRules": "InsertAsSnippet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find Untraced Code
|
||||||
|
```bash
|
||||||
|
# Files modified without TRACES
|
||||||
|
git diff --name-only | xargs grep -L "TRACES:" | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Do I need TRACES on every function?**
|
||||||
|
A: Only for code that implements requirements. Internal helpers don't need TRACES.
|
||||||
|
|
||||||
|
**Q: Can I use TRACES on multiple related functions?**
|
||||||
|
A: Yes! Add at the file/module level or on individual functions.
|
||||||
|
|
||||||
|
**Q: What if code doesn't relate to any requirement?**
|
||||||
|
A: Leave it untraced. TRACES are for requirement-driven development.
|
||||||
|
|
||||||
|
**Q: How often should I regenerate reports?**
|
||||||
|
A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`
|
||||||
|
|
||||||
|
**Q: Can I trace to requirements that aren't implemented yet?**
|
||||||
|
A: Yes! TRACES show your implementation plan.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Full Traceability Matrix](docs/traceability.md)
|
||||||
|
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
||||||
|
- [Requirements Specification](README.md)
|
||||||
|
- [Extraction Script](scripts/README.md#extract-tracests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Quick Start:**
|
||||||
|
1. Add `// TRACES: UR-XXX` to new code
|
||||||
|
2. Run `bun run traces:markdown`
|
||||||
|
3. Check `docs/traceability.md`
|
||||||
|
4. Submit PR - workflow validates automatically!
|
||||||
Generated
-10227
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -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,6 +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 && 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",
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"traces": "bun run scripts/extract-traces.ts",
|
"traces": "bun run scripts/extract-traces.ts",
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md"
|
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -56,7 +58,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": ">=1.0.0 <5.0.0",
|
||||||
"webdriverio": "^9.5.0"
|
"webdriverio": "^9.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -68,7 +68,7 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
|
|||||||
```bash
|
```bash
|
||||||
bun run traces # Generate markdown report
|
bun run traces # Generate markdown report
|
||||||
bun run traces:json # Generate JSON report
|
bun run traces:json # Generate JSON report
|
||||||
bun run traces:markdown # Save to docs/TRACEABILITY.md
|
bun run traces:markdown # Save to docs/traceability.md
|
||||||
```
|
```
|
||||||
|
|
||||||
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
|
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
|
||||||
@@ -82,7 +82,7 @@ Example TRACES comment in code:
|
|||||||
function handlePlayback() { ... }
|
function handlePlayback() { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/TRACEABILITY.md](../docs/TRACEABILITY.md) for the latest generated mapping.
|
See [docs/traceability.md](../docs/traceability.md) for the latest generated mapping.
|
||||||
|
|
||||||
### CI/CD Validation
|
### CI/CD Validation
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ The traceability system is integrated with Gitea Actions CI/CD:
|
|||||||
- Generates traceability reports automatically
|
- Generates traceability reports automatically
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ echo ""
|
|||||||
# Build type: debug or release (default: debug)
|
# Build type: debug or release (default: debug)
|
||||||
BUILD_TYPE="${1:-debug}"
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
# Step 0: Clear build caches to ensure fresh builds
|
||||||
|
echo "🧹 Clearing build caches..."
|
||||||
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
|
npm install > /dev/null 2>&1
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
./scripts/sync-android-sources.sh
|
./scripts/sync-android-sources.sh
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Usage:
|
* Usage:
|
||||||
* bun run scripts/extract-traces.ts
|
* bun run scripts/extract-traces.ts
|
||||||
* bun run scripts/extract-traces.ts --format json
|
* bun run scripts/extract-traces.ts --format json
|
||||||
* bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md
|
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,14 @@ for kt_file in "$SOURCE_DIR"/*.kt; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Restore the app module build.gradle.kts (media3 deps + release signing config).
|
||||||
|
# gen/android is regenerated by `tauri android init`, so this tracked template is
|
||||||
|
# the source of truth and must be copied back after any (re)generation.
|
||||||
|
APP_GRADLE_SRC="$PROJECT_ROOT/src-tauri/android/app/build.gradle.kts"
|
||||||
|
APP_GRADLE_DST="$PROJECT_ROOT/src-tauri/gen/android/app/build.gradle.kts"
|
||||||
|
if [ -f "$APP_GRADLE_SRC" ]; then
|
||||||
|
cp "$APP_GRADLE_SRC" "$APP_GRADLE_DST"
|
||||||
|
echo " Copied: app/build.gradle.kts"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun test
|
bun run test
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🦀 Running Rust tests..."
|
echo "🦀 Running Rust tests..."
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun test "$@"
|
bun run test "$@"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|
||||||
Generated
+107
-42
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "Inflector"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1745,7 +1751,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core 0.62.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2011,14 +2017,18 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"specta",
|
||||||
|
"specta-typescript",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
|
"tauri-specta",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rusqlite",
|
"tokio-rusqlite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2795,6 +2805,12 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3933,6 +3949,51 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta"
|
||||||
|
version = "2.0.0-rc.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"paste",
|
||||||
|
"specta-macros",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-macros"
|
||||||
|
version = "2.0.0-rc.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
|
||||||
|
dependencies = [
|
||||||
|
"Inflector",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.112",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-serde"
|
||||||
|
version = "0.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
|
||||||
|
dependencies = [
|
||||||
|
"specta",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-typescript"
|
||||||
|
version = "0.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
|
||||||
|
dependencies = [
|
||||||
|
"specta",
|
||||||
|
"specta-serde",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4092,7 +4153,7 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
@@ -4149,6 +4210,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"serialize-to-javascript",
|
"serialize-to-javascript",
|
||||||
|
"specta",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-macros",
|
"tauri-macros",
|
||||||
@@ -4337,6 +4399,34 @@ dependencies = [
|
|||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-specta"
|
||||||
|
version = "2.0.0-rc.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"specta",
|
||||||
|
"specta-typescript",
|
||||||
|
"tauri",
|
||||||
|
"tauri-specta-macros",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-specta-macros"
|
||||||
|
version = "2.0.0-rc.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.112",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
@@ -4870,6 +4960,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5143,7 +5239,7 @@ dependencies = [
|
|||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
]
|
]
|
||||||
@@ -5167,7 +5263,7 @@ checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5223,7 +5319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-future",
|
"windows-future",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics",
|
||||||
@@ -5235,7 +5331,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5247,21 +5343,8 @@ dependencies = [
|
|||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-core"
|
|
||||||
version = "0.62.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|
||||||
dependencies = [
|
|
||||||
"windows-implement",
|
|
||||||
"windows-interface",
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
"windows-result 0.4.1",
|
|
||||||
"windows-strings 0.5.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5270,7 +5353,7 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
@@ -5315,7 +5398,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5328,15 +5411,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-result"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -5346,15 +5420,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-strings"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
@@ -5750,7 +5815,7 @@ dependencies = [
|
|||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ edition = "2021"
|
|||||||
name = "jellytau_lib"
|
name = "jellytau_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
# Keep debug info minimal to reduce target/ size in CI (line numbers in
|
||||||
|
# backtraces are preserved; the bulky full debuginfo is dropped).
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ rand = "0.8"
|
|||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
@@ -45,6 +51,9 @@ sha2 = "0.10"
|
|||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||||
|
specta-typescript = "=0.0.9"
|
||||||
|
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||||
|
|
||||||
# Linux-specific dependencies
|
# Linux-specific dependencies
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release signing: loaded from gen/android/keystore.properties if present.
|
||||||
|
// Falls back to no signing config (debug-signed) when the file is absent.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propFile = rootProject.file("keystore.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "com.dtourolle.jellytau"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "com.dtourolle.jellytau"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keystoreProperties.getProperty("storeFile")?.let {
|
||||||
|
storeFile = file(it)
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
if (keystoreProperties.getProperty("storeFile") != null) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
|
// Media3 dependencies for audio playback
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-session:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.5.0")
|
||||||
|
implementation("com.google.guava:guava:33.0.0-android")
|
||||||
|
|
||||||
|
// Media library for VolumeProviderCompat (remote volume control)
|
||||||
|
implementation("androidx.media:media:1.7.0")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.LruCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory cache for album artwork bitmaps with LRU eviction.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
|
||||||
|
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
|
||||||
|
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
|
||||||
|
* - Graceful error handling for network failures and corrupted images
|
||||||
|
* - Singleton pattern for app-wide access
|
||||||
|
*
|
||||||
|
* Cache lifecycle: In-memory only, cleared on app termination.
|
||||||
|
*/
|
||||||
|
class AlbumArtCache(context: Context) {
|
||||||
|
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
||||||
|
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
|
||||||
|
|
||||||
|
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||||
|
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||||
|
return bitmap.byteCount / 1024 // Size in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get artwork bitmap for the given URL.
|
||||||
|
*
|
||||||
|
* Checks memory cache first, then downloads from network if not cached.
|
||||||
|
* Scaling and error handling are done transparently.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The bitmap, or null if download failed or URL is invalid
|
||||||
|
*/
|
||||||
|
suspend fun getArtwork(url: String): Bitmap? {
|
||||||
|
// Check memory cache first
|
||||||
|
memoryCache.get(url)?.let { return it }
|
||||||
|
|
||||||
|
// Download from network if not cached
|
||||||
|
return downloadAndCache(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download artwork from network and add to cache.
|
||||||
|
*
|
||||||
|
* Runs on IO dispatcher to avoid blocking the main thread.
|
||||||
|
* Automatically scales large images to 512x512 max.
|
||||||
|
* On failure, logs error and returns null.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The cached bitmap, or null if download/decode failed
|
||||||
|
*/
|
||||||
|
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
connection.doInput = true
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.readTimeout = 5000
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val input = connection.inputStream
|
||||||
|
val bitmap = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
connection.disconnect()
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
// Scale down if too large (lock screen doesn't need full resolution)
|
||||||
|
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
|
||||||
|
memoryCache.put(url, scaled)
|
||||||
|
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
|
||||||
|
scaled
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
|
||||||
|
*
|
||||||
|
* @param bitmap The original bitmap
|
||||||
|
* @param maxSize Maximum width or height (e.g., 512)
|
||||||
|
* @return Original bitmap if smaller than maxSize, else scaled version
|
||||||
|
*/
|
||||||
|
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
|
||||||
|
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
|
||||||
|
|
||||||
|
val ratio = minOf(
|
||||||
|
maxSize.toFloat() / bitmap.width,
|
||||||
|
maxSize.toFloat() / bitmap.height
|
||||||
|
)
|
||||||
|
|
||||||
|
val newWidth = (bitmap.width * ratio).toInt()
|
||||||
|
val newHeight = (bitmap.height * ratio).toInt()
|
||||||
|
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached bitmaps from memory.
|
||||||
|
*
|
||||||
|
* Useful for memory-constrained situations or settings reset.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
memoryCache.evictAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: AlbumArtCache? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create singleton instance.
|
||||||
|
*
|
||||||
|
* Thread-safe with double-checked locking pattern.
|
||||||
|
*
|
||||||
|
* @param context Android context for initialization
|
||||||
|
* @return The singleton AlbumArtCache instance
|
||||||
|
*/
|
||||||
|
fun getInstance(context: Context): AlbumArtCache {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: AlbumArtCache(context).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,11 +138,22 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Current media ID being played */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
|
||||||
|
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
|
||||||
|
* end of a transcoded stream), which would otherwise notify the backend
|
||||||
|
* twice and, for example, decrement the sleep-timer episode counter twice.
|
||||||
|
* Reset whenever new media is loaded.
|
||||||
|
*/
|
||||||
|
private var endedNotified = false
|
||||||
|
|
||||||
/** Current media metadata for notification updates */
|
/** Current media metadata for notification updates */
|
||||||
private var currentTitle: String = ""
|
private var currentTitle: String = ""
|
||||||
private var currentArtist: String = ""
|
private var currentArtist: String = ""
|
||||||
private var currentAlbum: String? = null
|
private var currentAlbum: String? = null
|
||||||
private var currentDurationMs: Long = 0
|
private var currentDurationMs: Long = 0
|
||||||
|
private var currentArtworkUrl: String? = null
|
||||||
|
private var currentArtworkBitmap: android.graphics.Bitmap? = null
|
||||||
|
|
||||||
/** Media type enum */
|
/** Media type enum */
|
||||||
enum class MediaType { AUDIO, VIDEO }
|
enum class MediaType { AUDIO, VIDEO }
|
||||||
@@ -169,6 +180,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Create ExoPlayer with audio focus handling
|
// Create ExoPlayer with audio focus handling
|
||||||
exoPlayer = ExoPlayer.Builder(appContext)
|
exoPlayer = ExoPlayer.Builder(appContext)
|
||||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||||
|
// Pause when the audio output is removed (wired headphones unplugged or
|
||||||
|
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||||
|
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
|
||||||
|
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
|
||||||
|
// the lockscreen notification in sync automatically.
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Set up player listener
|
// Set up player listener
|
||||||
@@ -200,7 +217,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Playback completed
|
// Playback completed
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||||
stopPositionUpdates()
|
stopPositionUpdates()
|
||||||
nativeOnPlaybackEnded()
|
// Only notify the backend once per loaded media. ExoPlayer
|
||||||
|
// can re-enter STATE_ENDED, which would double-count things
|
||||||
|
// like the sleep-timer episode counter.
|
||||||
|
if (!endedNotified) {
|
||||||
|
endedNotified = true
|
||||||
|
nativeOnPlaybackEnded()
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Player.STATE_BUFFERING -> {
|
Player.STATE_BUFFERING -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
||||||
@@ -314,6 +339,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun load(url: String, mediaId: String) {
|
fun load(url: String, mediaId: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
@@ -544,12 +570,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
|
|
||||||
// Store metadata for notification updates
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
currentArtist = artist ?: ""
|
currentArtist = artist ?: ""
|
||||||
currentAlbum = album
|
currentAlbum = album
|
||||||
currentDurationMs = durationMs
|
currentDurationMs = durationMs
|
||||||
|
currentArtworkUrl = artworkUrl
|
||||||
|
currentArtworkBitmap = null // Reset on new track
|
||||||
|
|
||||||
// Detect media type
|
// Detect media type
|
||||||
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
|
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
|
||||||
@@ -666,6 +695,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
// Start the foreground service for lockscreen controls
|
// Start the foreground service for lockscreen controls
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
|
|
||||||
|
// Download album art asynchronously (non-blocking)
|
||||||
|
currentArtworkUrl?.let { url ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val bitmap = AlbumArtCache.getInstance(appContext).getArtwork(url)
|
||||||
|
currentArtworkBitmap = bitmap
|
||||||
|
updatePlaybackServiceNotification(exoPlayer.isPlaying)
|
||||||
|
android.util.Log.d("JellyTauPlayer", "Album art loaded: ${bitmap?.width}x${bitmap?.height}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("JellyTauPlayer", "Failed to load album art", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-38
@@ -10,8 +10,9 @@ use crate::connectivity::ConnectivityMonitor;
|
|||||||
pub use session_verifier::SessionVerifier;
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
/// Server information returned from Jellyfin
|
/// Server information returned from Jellyfin
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[specta(rename = "AuthServerInfo")]
|
||||||
pub struct ServerInfo {
|
pub struct ServerInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@@ -21,7 +22,7 @@ pub struct ServerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User information
|
/// User information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -31,7 +32,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication result
|
/// Authentication result
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AuthResult {
|
pub struct AuthResult {
|
||||||
pub user: User,
|
pub user: User,
|
||||||
@@ -40,7 +41,7 @@ pub struct AuthResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Active session for restoration
|
/// Active session for restoration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
@@ -55,7 +56,7 @@ pub struct Session {
|
|||||||
|
|
||||||
// Jellyfin API response types (PascalCase from server)
|
// Jellyfin API response types (PascalCase from server)
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct PublicSystemInfo {
|
struct PublicSystemInfo {
|
||||||
server_name: String,
|
server_name: String,
|
||||||
@@ -63,7 +64,7 @@ struct PublicSystemInfo {
|
|||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct AuthenticateByNameResponse {
|
struct AuthenticateByNameResponse {
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
@@ -71,7 +72,7 @@ struct AuthenticateByNameResponse {
|
|||||||
server_id: String,
|
server_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct JellyfinUser {
|
struct JellyfinUser {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -102,12 +103,18 @@ impl AuthManager {
|
|||||||
self.connectivity_monitor = Some(monitor);
|
self.connectivity_monitor = Some(monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize and validate server URL
|
/// Normalize and validate server URL.
|
||||||
pub fn normalize_url(url: &str) -> String {
|
/// Enforces HTTPS — plain HTTP is rejected for security.
|
||||||
|
pub fn normalize_url(url: &str) -> Result<String, String> {
|
||||||
let mut normalized = url.trim().to_string();
|
let mut normalized = url.trim().to_string();
|
||||||
|
|
||||||
|
// Reject plain HTTP — all connections must use HTTPS
|
||||||
|
if normalized.starts_with("http://") {
|
||||||
|
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Add https:// if no protocol specified
|
// Add https:// if no protocol specified
|
||||||
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
if !normalized.starts_with("https://") {
|
||||||
normalized = format!("https://{}", normalized);
|
normalized = format!("https://{}", normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +123,12 @@ impl AuthManager {
|
|||||||
normalized.pop();
|
normalized.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to server and get server info
|
/// Connect to server and get server info
|
||||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
let normalized_url = Self::normalize_url(server_url);
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
@@ -165,7 +172,7 @@ impl AuthManager {
|
|||||||
password: &str,
|
password: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
@@ -227,7 +234,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/{}", url, user_id);
|
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||||
|
|
||||||
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||||
@@ -290,7 +297,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Sessions/Logout", url);
|
let endpoint = format!("{}/Sessions/Logout", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Logging out");
|
log::info!("[AuthManager] Logging out");
|
||||||
@@ -337,43 +344,43 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Test URL normalization - adds https:// when missing
|
/// Test URL normalization - adds https:// when missing
|
||||||
///
|
|
||||||
/// Ensures that URLs without protocol are normalized to https://
|
|
||||||
/// This prevents "builder error" when constructing HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_adds_https() {
|
fn test_normalize_url_adds_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com"),
|
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("192.168.1.100:8096"),
|
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
|
||||||
"https://192.168.1.100:8096"
|
"https://192.168.1.100:8096"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - preserves existing protocol
|
/// Test URL normalization - preserves existing https
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_preserves_protocol() {
|
fn test_normalize_url_preserves_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com"),
|
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
AuthManager::normalize_url("http://localhost:8096"),
|
|
||||||
"http://localhost:8096"
|
/// Test URL normalization - rejects HTTP
|
||||||
);
|
#[test]
|
||||||
|
fn test_normalize_url_rejects_http() {
|
||||||
|
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
|
||||||
|
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - removes trailing slash
|
/// Test URL normalization - removes trailing slash
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_removes_trailing_slash() {
|
fn test_normalize_url_removes_trailing_slash() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com/"),
|
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,25 +389,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_trims_whitespace() {
|
fn test_normalize_url_trims_whitespace() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" jellyfin.example.com "),
|
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - complex case
|
/// Test URL normalization - real world case
|
||||||
///
|
|
||||||
/// This is the bug that caused the login issue: user enters URL
|
|
||||||
/// without protocol, it gets stored in DB, then fails when building
|
|
||||||
/// HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_real_world_case() {
|
fn test_normalize_url_real_world_case() {
|
||||||
// User input: "jellyfin.tourolle.paris"
|
|
||||||
let input = "jellyfin.tourolle.paris";
|
let input = "jellyfin.tourolle.paris";
|
||||||
let normalized = AuthManager::normalize_url(input);
|
let normalized = AuthManager::normalize_url(input).unwrap();
|
||||||
|
|
||||||
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||||
assert!(normalized.starts_with("https://"));
|
assert!(normalized.starts_with("https://"));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerif
|
|||||||
/// Initialize the auth manager (call on app startup)
|
/// Initialize the auth manager (call on app startup)
|
||||||
/// Restores session from storage if available
|
/// Restores session from storage if available
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_initialize(
|
pub async fn auth_initialize(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
database: State<'_, crate::commands::DatabaseWrapper>,
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
||||||
@@ -39,7 +40,7 @@ pub async fn auth_initialize(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create session object from active session with normalized URL
|
// Create session object from active session with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: active_session.user_id,
|
user_id: active_session.user_id,
|
||||||
@@ -61,6 +62,7 @@ pub async fn auth_initialize(
|
|||||||
|
|
||||||
/// Connect to a Jellyfin server and get server info
|
/// Connect to a Jellyfin server and get server info
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_connect_to_server(
|
pub async fn auth_connect_to_server(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
@@ -70,6 +72,7 @@ pub async fn auth_connect_to_server(
|
|||||||
|
|
||||||
/// Login with username and password
|
/// Login with username and password
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_login(
|
pub async fn auth_login(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -80,7 +83,7 @@ pub async fn auth_login(
|
|||||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||||
|
|
||||||
// Create session from auth result with normalized URL
|
// Create session from auth result with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: result.user.id.clone(),
|
user_id: result.user.id.clone(),
|
||||||
@@ -100,6 +103,7 @@ pub async fn auth_login(
|
|||||||
|
|
||||||
/// Verify current session
|
/// Verify current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_verify_session(
|
pub async fn auth_verify_session(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -118,6 +122,7 @@ pub async fn auth_verify_session(
|
|||||||
|
|
||||||
/// Logout (clear session and call Jellyfin logout endpoint)
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_logout(
|
pub async fn auth_logout(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -143,6 +148,7 @@ pub async fn auth_logout(
|
|||||||
|
|
||||||
/// Get current session
|
/// Get current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_get_session(
|
pub async fn auth_get_session(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<Option<Session>, String> {
|
) -> Result<Option<Session>, String> {
|
||||||
@@ -151,15 +157,19 @@ pub async fn auth_get_session(
|
|||||||
|
|
||||||
/// Set current session (for restoration from storage)
|
/// Set current session (for restoration from storage)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_set_session(
|
pub async fn auth_set_session(
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Normalize the server URL if session is provided
|
// Normalize the server URL if session is provided
|
||||||
let normalized_session = session.map(|mut s| {
|
let normalized_session = match session {
|
||||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
Some(mut s) => {
|
||||||
s
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||||
});
|
Some(s)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
auth_manager.0.set_session(normalized_session).await;
|
auth_manager.0.set_session(normalized_session).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,6 +177,7 @@ pub async fn auth_set_session(
|
|||||||
|
|
||||||
/// Start background session verification
|
/// Start background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_start_verification(
|
pub async fn auth_start_verification(
|
||||||
device_id: String,
|
device_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -195,6 +206,7 @@ pub async fn auth_start_verification(
|
|||||||
|
|
||||||
/// Stop background session verification
|
/// Stop background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_stop_verification(
|
pub async fn auth_stop_verification(
|
||||||
session_verifier: State<'_, SessionVerifierWrapper>,
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -209,6 +221,7 @@ pub async fn auth_stop_verification(
|
|||||||
|
|
||||||
/// Re-authenticate with password (when session expired)
|
/// Re-authenticate with password (when session expired)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_reauthenticate(
|
pub async fn auth_reauthenticate(
|
||||||
password: String,
|
password: String,
|
||||||
device_id: String,
|
device_id: String,
|
||||||
@@ -237,3 +250,197 @@ pub async fn auth_reauthenticate(
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_serialization() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-123".to_string(),
|
||||||
|
username: "john_doe".to_string(),
|
||||||
|
server_id: "server-456".to_string(),
|
||||||
|
server_url: "https://jellyfin.example.com".to_string(),
|
||||||
|
server_name: "My Jellyfin".to_string(),
|
||||||
|
access_token: "token-789-xyz".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should serialize successfully
|
||||||
|
let json = serde_json::to_string(&session);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("user-123"));
|
||||||
|
assert!(serialized.contains("john_doe"));
|
||||||
|
assert!(serialized.contains("server-456"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_deserialization() {
|
||||||
|
let json = r#"{
|
||||||
|
"userId": "user-123",
|
||||||
|
"username": "john_doe",
|
||||||
|
"serverId": "server-456",
|
||||||
|
"serverUrl": "https://jellyfin.example.com",
|
||||||
|
"serverName": "My Jellyfin",
|
||||||
|
"accessToken": "token-789",
|
||||||
|
"verified": true,
|
||||||
|
"needsReauth": false
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let result: Result<Session, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let session = result.unwrap();
|
||||||
|
assert_eq!(session.user_id, "user-123");
|
||||||
|
assert_eq!(session.username, "john_doe");
|
||||||
|
assert_eq!(session.server_id, "server-456");
|
||||||
|
assert!(session.verified);
|
||||||
|
assert!(!session.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_roundtrip() {
|
||||||
|
let original = Session {
|
||||||
|
user_id: "user-999".to_string(),
|
||||||
|
username: "alice".to_string(),
|
||||||
|
server_id: "server-111".to_string(),
|
||||||
|
server_url: "https://server.local".to_string(),
|
||||||
|
server_name: "Home Server".to_string(),
|
||||||
|
access_token: "very-long-token-string".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(original.user_id, deserialized.user_id);
|
||||||
|
assert_eq!(original.username, deserialized.username);
|
||||||
|
assert_eq!(original.server_id, deserialized.server_id);
|
||||||
|
assert_eq!(original.server_url, deserialized.server_url);
|
||||||
|
assert_eq!(original.access_token, deserialized.access_token);
|
||||||
|
assert_eq!(original.verified, deserialized.verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_clone() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-clone".to_string(),
|
||||||
|
username: "test_user".to_string(),
|
||||||
|
server_id: "server-clone".to_string(),
|
||||||
|
server_url: "https://clone.example.com".to_string(),
|
||||||
|
server_name: "Clone Server".to_string(),
|
||||||
|
access_token: "clone-token".to_string(),
|
||||||
|
verified: false,
|
||||||
|
needs_reauth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = session.clone();
|
||||||
|
assert_eq!(session.user_id, cloned.user_id);
|
||||||
|
assert_eq!(session.username, cloned.username);
|
||||||
|
assert_eq!(session.verified, cloned.verified);
|
||||||
|
assert_eq!(session.needs_reauth, cloned.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_unverified() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-unverified".to_string(),
|
||||||
|
username: "newuser".to_string(),
|
||||||
|
server_id: "server-new".to_string(),
|
||||||
|
server_url: "https://new.example.com".to_string(),
|
||||||
|
server_name: "New Server".to_string(),
|
||||||
|
access_token: "new-token".to_string(),
|
||||||
|
verified: false,
|
||||||
|
needs_reauth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
assert!(json.contains("false")); // verified: false
|
||||||
|
assert!(json.contains("true")); // needs_reauth: true
|
||||||
|
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(!deserialized.verified);
|
||||||
|
assert!(deserialized.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_debug() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-debug".to_string(),
|
||||||
|
username: "debug_user".to_string(),
|
||||||
|
server_id: "server-debug".to_string(),
|
||||||
|
server_url: "https://debug.example.com".to_string(),
|
||||||
|
server_name: "Debug Server".to_string(),
|
||||||
|
access_token: "debug-token".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", session);
|
||||||
|
assert!(debug_str.contains("user-debug"));
|
||||||
|
assert!(debug_str.contains("Session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_manager_wrapper_structure() {
|
||||||
|
// Verify wrapper type exists and has correct structure
|
||||||
|
assert_eq!(std::mem::size_of::<AuthManagerWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_verifier_wrapper_structure() {
|
||||||
|
// Verify wrapper type exists and has correct structure
|
||||||
|
assert_eq!(std::mem::size_of::<SessionVerifierWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_with_special_characters() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-special-éñ".to_string(),
|
||||||
|
username: "user@example.com".to_string(),
|
||||||
|
server_id: "server/123".to_string(),
|
||||||
|
server_url: "https://jellyfin.example.com:8096".to_string(),
|
||||||
|
server_name: "My Jellyfin (v10.8.0)".to_string(),
|
||||||
|
access_token: "token+with/special=chars".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.username, deserialized.username);
|
||||||
|
assert_eq!(session.server_name, deserialized.server_name);
|
||||||
|
assert_eq!(session.access_token, deserialized.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_field_presence() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "u1".to_string(),
|
||||||
|
username: "user1".to_string(),
|
||||||
|
server_id: "s1".to_string(),
|
||||||
|
server_url: "url1".to_string(),
|
||||||
|
server_name: "name1".to_string(),
|
||||||
|
access_token: "token1".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
|
||||||
|
// Verify camelCase serialization (serde rename_all = "camelCase")
|
||||||
|
assert!(json.contains("userId"));
|
||||||
|
assert!(json.contains("username"));
|
||||||
|
assert!(json.contains("serverId"));
|
||||||
|
assert!(json.contains("serverUrl"));
|
||||||
|
assert!(json.contains("serverName"));
|
||||||
|
assert!(json.contains("accessToken"));
|
||||||
|
assert!(json.contains("verified"));
|
||||||
|
assert!(json.contains("needsReauth"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMon
|
|||||||
|
|
||||||
/// Check if the server is currently reachable
|
/// Check if the server is currently reachable
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_check_server(
|
pub async fn connectivity_check_server(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -16,6 +17,7 @@ pub async fn connectivity_check_server(
|
|||||||
|
|
||||||
/// Set the server URL and trigger an immediate check
|
/// Set the server URL and trigger an immediate check
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_set_server_url(
|
pub async fn connectivity_set_server_url(
|
||||||
url: String,
|
url: String,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -27,6 +29,7 @@ pub async fn connectivity_set_server_url(
|
|||||||
|
|
||||||
/// Get the current connectivity status
|
/// Get the current connectivity status
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_get_status(
|
pub async fn connectivity_get_status(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<ConnectivityStatus, String> {
|
) -> Result<ConnectivityStatus, String> {
|
||||||
@@ -36,6 +39,7 @@ pub async fn connectivity_get_status(
|
|||||||
|
|
||||||
/// Start monitoring connectivity with adaptive polling
|
/// Start monitoring connectivity with adaptive polling
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_start_monitoring(
|
pub async fn connectivity_start_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -46,6 +50,7 @@ pub async fn connectivity_start_monitoring(
|
|||||||
|
|
||||||
/// Stop monitoring connectivity
|
/// Stop monitoring connectivity
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_stop_monitoring(
|
pub async fn connectivity_stop_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -56,6 +61,7 @@ pub async fn connectivity_stop_monitoring(
|
|||||||
|
|
||||||
/// Mark the server as reachable (called after successful API calls)
|
/// Mark the server as reachable (called after successful API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_reachable(
|
pub async fn connectivity_mark_reachable(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -66,6 +72,7 @@ pub async fn connectivity_mark_reachable(
|
|||||||
|
|
||||||
/// Mark the server as unreachable (called after failed API calls)
|
/// Mark the server as unreachable (called after failed API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_unreachable(
|
pub async fn connectivity_mark_unreachable(
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -74,3 +81,18 @@ pub async fn connectivity_mark_unreachable(
|
|||||||
monitor.mark_unreachable(error).await;
|
monitor.mark_unreachable(error).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connectivity_monitor_wrapper_structure() {
|
||||||
|
// Test that wrapper can be created and holds Arc
|
||||||
|
// We can't instantiate ConnectivityMonitor directly in tests
|
||||||
|
// due to its dependencies, so we just test the wrapper type structure
|
||||||
|
|
||||||
|
// This verifies the wrapper type exists and can hold Arc<Mutex>
|
||||||
|
assert_eq!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::utils::conversions::{
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "3:45" or "12:09"
|
/// Formatted string like "3:45" or "12:09"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds(seconds: f64) -> String {
|
pub fn format_time_seconds(seconds: f64) -> String {
|
||||||
format_time(seconds)
|
format_time(seconds)
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ pub fn format_time_seconds(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "1:23:45" or "3:45"
|
/// Formatted string like "1:23:45" or "3:45"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds_long(seconds: f64) -> String {
|
pub fn format_time_seconds_long(seconds: f64) -> String {
|
||||||
format_time_long(seconds)
|
format_time_long(seconds)
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ pub fn format_time_seconds_long(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Time in seconds
|
/// Time in seconds
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
||||||
ticks_to_seconds(ticks)
|
ticks_to_seconds(ticks)
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Progress as percentage (0.0 to 100.0)
|
/// Progress as percentage (0.0 to 100.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
||||||
calculate_progress(position, duration)
|
calculate_progress(position, duration)
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,57 @@ pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Normalized volume (0.0 to 1.0)
|
/// Normalized volume (0.0 to 1.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
||||||
percent_to_volume(percent)
|
percent_to_volume(percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_time_seconds() {
|
||||||
|
assert_eq!(format_time_seconds(0.0), "0:00");
|
||||||
|
assert_eq!(format_time_seconds(59.0), "0:59");
|
||||||
|
assert_eq!(format_time_seconds(60.0), "1:00");
|
||||||
|
assert_eq!(format_time_seconds(125.0), "2:05");
|
||||||
|
assert_eq!(format_time_seconds(3661.0), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_time_seconds_long() {
|
||||||
|
assert_eq!(format_time_seconds_long(0.0), "0:00");
|
||||||
|
assert_eq!(format_time_seconds_long(59.0), "0:59");
|
||||||
|
assert_eq!(format_time_seconds_long(3599.0), "59:59");
|
||||||
|
assert_eq!(format_time_seconds_long(3600.0), "1:00:00");
|
||||||
|
assert_eq!(format_time_seconds_long(3661.0), "1:01:01");
|
||||||
|
assert_eq!(format_time_seconds_long(7384.0), "2:03:04");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_ticks_to_seconds() {
|
||||||
|
assert_eq!(convert_ticks_to_seconds(0), 0.0);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(10_000_000), 1.0);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(5_000_000), 0.5);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(60_000_000), 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calc_progress() {
|
||||||
|
assert_eq!(calc_progress(0.0, 100.0), 0.0);
|
||||||
|
assert_eq!(calc_progress(50.0, 100.0), 50.0);
|
||||||
|
assert_eq!(calc_progress(100.0, 100.0), 100.0);
|
||||||
|
assert_eq!(calc_progress(25.0, 0.0), 0.0); // Invalid duration
|
||||||
|
assert_eq!(calc_progress(150.0, 100.0), 100.0); // Clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_percent_to_volume() {
|
||||||
|
assert_eq!(convert_percent_to_volume(0.0), 0.0);
|
||||||
|
assert_eq!(convert_percent_to_volume(50.0), 0.5);
|
||||||
|
assert_eq!(convert_percent_to_volume(100.0), 1.0);
|
||||||
|
assert_eq!(convert_percent_to_volume(150.0), 1.0); // Clamped
|
||||||
|
assert_eq!(convert_percent_to_volume(-10.0), 0.0); // Clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-009 | DR-011
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -79,6 +80,7 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-009 | DR-011
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// Pin an item's metadata (protects from cache clear)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE items SET is_pinned = 1 WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpin an item's metadata
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE items SET is_pinned = 0 WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an item is pinned
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let is_pinned: i32 = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(is_pinned == 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Smart-cache statistics/config and album recommendation commands.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use log::info;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// SmartCache statistics
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SmartCacheStats {
|
||||||
|
pub total_size: u64,
|
||||||
|
pub storage_limit: u64,
|
||||||
|
pub available_space: u64,
|
||||||
|
pub items_count: i64,
|
||||||
|
pub config: crate::download::cache::CacheConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SmartCache statistics
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_smart_cache_stats(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<SmartCacheStats, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone cache to avoid holding lock across async operations
|
||||||
|
let cache = {
|
||||||
|
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_size = cache
|
||||||
|
.get_total_download_size_async(&db_service, &user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let config = cache
|
||||||
|
.get_config()
|
||||||
|
.ok_or_else(|| "Failed to get cache config".to_string())?;
|
||||||
|
|
||||||
|
let storage_limit = config.storage_limit;
|
||||||
|
let available_space = storage_limit.saturating_sub(total_size);
|
||||||
|
|
||||||
|
// Get item count
|
||||||
|
let count_query = Query::with_params(
|
||||||
|
"SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'",
|
||||||
|
vec![QueryParam::String(user_id)],
|
||||||
|
);
|
||||||
|
let items_count: i64 = db_service
|
||||||
|
.query_one(count_query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(SmartCacheStats {
|
||||||
|
total_size,
|
||||||
|
storage_limit,
|
||||||
|
available_space,
|
||||||
|
items_count,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update SmartCache configuration
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn update_smart_cache_config(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
config: crate::download::cache::CacheConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
cache.update_config(config);
|
||||||
|
info!("Updated SmartCache configuration");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SmartCache configuration
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_smart_cache_config(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
) -> Result<crate::download::cache::CacheConfig, String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
cache
|
||||||
|
.get_config()
|
||||||
|
.ok_or_else(|| "Failed to get cache config".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album recommendation info
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AlbumRecommendation {
|
||||||
|
pub album_id: String,
|
||||||
|
pub album_name: String,
|
||||||
|
pub tracks_played: usize,
|
||||||
|
pub total_tracks: usize,
|
||||||
|
pub should_download: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album affinity status info
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AlbumAffinityStatus {
|
||||||
|
pub album_id: String,
|
||||||
|
pub unique_tracks_played: usize,
|
||||||
|
pub threshold: usize,
|
||||||
|
pub threshold_reached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get album recommendations based on play history
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_album_recommendations(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Vec<AlbumRecommendation>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone cache to avoid holding lock across async operations
|
||||||
|
let cache = {
|
||||||
|
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all albums that user has played tracks from
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT DISTINCT i.album_id, a.name
|
||||||
|
FROM user_data ud
|
||||||
|
JOIN items i ON ud.item_id = i.id
|
||||||
|
JOIN items a ON i.album_id = a.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
AND ud.play_count > 0
|
||||||
|
AND i.item_type = 'Audio'
|
||||||
|
AND i.album_id IS NOT NULL",
|
||||||
|
vec![QueryParam::String(user_id.clone())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let albums: Vec<(String, String)> = db_service
|
||||||
|
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut recommendations = Vec::new();
|
||||||
|
|
||||||
|
for (album_id, album_name) in albums {
|
||||||
|
// Check if should cache
|
||||||
|
let should_download = cache.should_cache_album(&album_id).unwrap_or(false);
|
||||||
|
|
||||||
|
// Get track counts
|
||||||
|
let tracks_query = Query::with_params(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(ud.id) as played
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ?
|
||||||
|
WHERE i.album_id = ? AND i.item_type = 'Audio'",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id.clone()),
|
||||||
|
QueryParam::String(album_id.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let (total_tracks, tracks_played): (i64, i64) = db_service
|
||||||
|
.query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.await
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
|
if tracks_played > 0 {
|
||||||
|
recommendations.push(AlbumRecommendation {
|
||||||
|
album_id,
|
||||||
|
album_name,
|
||||||
|
tracks_played: tracks_played as usize,
|
||||||
|
total_tracks: total_tracks as usize,
|
||||||
|
should_download,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by tracks played (descending)
|
||||||
|
recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played));
|
||||||
|
|
||||||
|
Ok(recommendations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get album affinity status for all tracked albums
|
||||||
|
/// This shows the SmartCache's internal play history and threshold status
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn get_album_affinity_status(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
) -> Result<Vec<AlbumAffinityStatus>, String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get the threshold from config
|
||||||
|
let threshold = cache
|
||||||
|
.get_config()
|
||||||
|
.map(|c| c.album_affinity_threshold)
|
||||||
|
.unwrap_or(3);
|
||||||
|
|
||||||
|
// Get all tracked albums with their play counts
|
||||||
|
let play_history = cache.get_album_play_history();
|
||||||
|
|
||||||
|
let mut statuses: Vec<AlbumAffinityStatus> = play_history
|
||||||
|
.into_iter()
|
||||||
|
.map(|(album_id, unique_tracks_played)| {
|
||||||
|
let threshold_reached = unique_tracks_played >= threshold;
|
||||||
|
AlbumAffinityStatus {
|
||||||
|
album_id,
|
||||||
|
unique_tracks_played,
|
||||||
|
threshold,
|
||||||
|
threshold_reached,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by play count (descending)
|
||||||
|
statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played));
|
||||||
|
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod offline;
|
|||||||
pub mod playback_mode;
|
pub mod playback_mode;
|
||||||
pub mod playback_reporting;
|
pub mod playback_reporting;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod playlist;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
@@ -25,6 +26,7 @@ pub use playback_mode::*;
|
|||||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||||
pub use playback_reporting::*;
|
pub use playback_reporting::*;
|
||||||
pub use player::*;
|
pub use player::*;
|
||||||
|
pub use playlist::*;
|
||||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
pub use storage::*;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tauri::State;
|
|||||||
use super::DatabaseWrapper;
|
use super::DatabaseWrapper;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OfflineItem {
|
pub struct OfflineItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -22,6 +22,7 @@ pub struct OfflineItem {
|
|||||||
|
|
||||||
/// Check if an item is available offline
|
/// Check if an item is available offline
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_is_available(
|
pub async fn offline_is_available(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -46,6 +47,7 @@ pub async fn offline_is_available(
|
|||||||
|
|
||||||
/// Get all offline items for a user
|
/// Get all offline items for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_get_items(
|
pub async fn offline_get_items(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -84,6 +86,7 @@ pub async fn offline_get_items(
|
|||||||
|
|
||||||
/// Search offline items
|
/// Search offline items
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_search(
|
pub async fn offline_search(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
|
|||||||
|
|
||||||
/// Get the current playback mode
|
/// Get the current playback mode
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_get_current(
|
pub fn playback_mode_get_current(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<PlaybackMode, String> {
|
) -> Result<PlaybackMode, String> {
|
||||||
@@ -16,6 +17,7 @@ pub fn playback_mode_get_current(
|
|||||||
|
|
||||||
/// Set the playback mode (internal/testing use)
|
/// Set the playback mode (internal/testing use)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_set(
|
pub fn playback_mode_set(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
mode: PlaybackMode,
|
mode: PlaybackMode,
|
||||||
@@ -26,6 +28,7 @@ pub fn playback_mode_set(
|
|||||||
|
|
||||||
/// Check if currently transferring between playback modes
|
/// Check if currently transferring between playback modes
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_is_transferring(
|
pub fn playback_mode_is_transferring(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -34,15 +37,18 @@ pub fn playback_mode_is_transferring(
|
|||||||
|
|
||||||
/// Transfer playback from local device to a remote Jellyfin session
|
/// Transfer playback from local device to a remote Jellyfin session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_remote(
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
position: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||||
session_id
|
session_id,
|
||||||
|
position
|
||||||
);
|
);
|
||||||
manager.0.transfer_to_remote(session_id).await
|
manager.0.transfer_to_remote(session_id, position).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from remote session back to local device
|
/// Transfer playback from remote session back to local device
|
||||||
@@ -51,6 +57,7 @@ pub async fn playback_mode_transfer_to_remote(
|
|||||||
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
||||||
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_local(
|
pub async fn playback_mode_transfer_to_local(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
current_item_id: String,
|
current_item_id: String,
|
||||||
@@ -69,6 +76,7 @@ pub async fn playback_mode_transfer_to_local(
|
|||||||
|
|
||||||
/// Get remote session status (for polling position/duration)
|
/// Get remote session status (for polling position/duration)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_get_remote_status(
|
pub async fn playback_mode_get_remote_status(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
player: State<'_, crate::commands::PlayerStateWrapper>,
|
player: State<'_, crate::commands::PlayerStateWrapper>,
|
||||||
@@ -119,7 +127,7 @@ pub async fn playback_mode_get_remote_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remote session status for UI updates
|
/// Remote session status for UI updates
|
||||||
#[derive(serde::Serialize)]
|
#[derive(specta::Type, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RemoteSessionStatus {
|
pub struct RemoteSessionStatus {
|
||||||
pub position: f64,
|
pub position: f64,
|
||||||
@@ -127,3 +135,132 @@ pub struct RemoteSessionStatus {
|
|||||||
pub is_playing: bool,
|
pub is_playing: bool,
|
||||||
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_serialization() {
|
||||||
|
// Test Local playback mode
|
||||||
|
let local_mode = PlaybackMode::Local;
|
||||||
|
let json = serde_json::to_string(&local_mode);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
|
||||||
|
// Test Remote playback mode
|
||||||
|
let remote_mode = PlaybackMode::Remote {
|
||||||
|
session_id: "session-123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&remote_mode);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_serialization() {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: 123.45,
|
||||||
|
duration: Some(600.0),
|
||||||
|
is_playing: true,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should serialize successfully
|
||||||
|
let json = serde_json::to_string(&status);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("123.45"));
|
||||||
|
assert!(serialized.contains("600"));
|
||||||
|
assert!(serialized.contains("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_with_no_duration() {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: 0.0,
|
||||||
|
duration: None,
|
||||||
|
is_playing: false,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains("null") || json.contains("\"duration\":null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_various_positions() {
|
||||||
|
let positions = vec![0.0, 30.5, 100.0, 3600.0];
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: pos,
|
||||||
|
duration: Some(7200.0),
|
||||||
|
is_playing: true,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains(&pos.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_deserialization_from_frontend() {
|
||||||
|
// Test what frontend sends for Idle mode
|
||||||
|
let idle_json = r#"{"type":"idle"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||||
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
|
// Test what frontend sends for Local mode
|
||||||
|
let local_json = r#"{"type":"local"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||||
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
|
// Test what frontend sends for Remote mode
|
||||||
|
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||||
|
match mode {
|
||||||
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||||
|
_ => panic!("Expected Remote mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_play_tracks_context_deserialization() {
|
||||||
|
use crate::commands::PlayTracksContext;
|
||||||
|
|
||||||
|
// Test Search context (the recently fixed issue)
|
||||||
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(search_json)
|
||||||
|
.expect("Failed to deserialize search context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Search { search_query } => {
|
||||||
|
assert_eq!(search_query, "test query");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Search context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Playlist context
|
||||||
|
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
||||||
|
.expect("Failed to deserialize playlist context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
||||||
|
assert_eq!(playlist_id, "pl-123");
|
||||||
|
assert_eq!(playlist_name, "My Playlist");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Playlist context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Custom context
|
||||||
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
||||||
|
.expect("Failed to deserialize custom context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Custom { label } => {
|
||||||
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Custom context"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>
|
|||||||
|
|
||||||
/// Initialize playback reporter (called after login)
|
/// Initialize playback reporter (called after login)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_init(
|
pub async fn playback_reporter_init(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -66,6 +67,7 @@ pub async fn playback_reporter_init(
|
|||||||
|
|
||||||
/// Destroy playback reporter (called on logout)
|
/// Destroy playback reporter (called on logout)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_destroy(
|
pub async fn playback_reporter_destroy(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -76,6 +78,7 @@ pub async fn playback_reporter_destroy(
|
|||||||
|
|
||||||
/// Report playback start
|
/// Report playback start
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_start(
|
pub async fn playback_report_start(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -110,6 +113,7 @@ pub async fn playback_report_start(
|
|||||||
|
|
||||||
/// Report playback progress
|
/// Report playback progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_progress(
|
pub async fn playback_report_progress(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -138,6 +142,7 @@ pub async fn playback_report_progress(
|
|||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_stopped(
|
pub async fn playback_report_stopped(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -164,6 +169,7 @@ pub async fn playback_report_stopped(
|
|||||||
|
|
||||||
/// Mark item as played
|
/// Mark item as played
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mark_played(
|
pub async fn playback_mark_played(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -182,3 +188,216 @@ pub async fn playback_mark_played(
|
|||||||
|
|
||||||
reporter_instance.report(operation, is_online).await
|
reporter_instance.report(operation, is_online).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_start_creation() {
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-123".to_string(),
|
||||||
|
position_ticks: 15_000_000,
|
||||||
|
context: Some(PlaybackContext {
|
||||||
|
context_type: "series".to_string(),
|
||||||
|
context_id: Some("series-456".to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify enum variant can be created and pattern matched
|
||||||
|
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
|
||||||
|
assert_eq!(item_id, "item-123");
|
||||||
|
assert_eq!(position_ticks, 15_000_000);
|
||||||
|
assert!(context.is_some());
|
||||||
|
let ctx = context.unwrap();
|
||||||
|
assert_eq!(ctx.context_type, "series");
|
||||||
|
assert_eq!(ctx.context_id, Some("series-456".to_string()));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Start variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_start_without_context() {
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-789".to_string(),
|
||||||
|
position_ticks: 5_000_000,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Start { item_id, context, .. } = operation {
|
||||||
|
assert_eq!(item_id, "item-789");
|
||||||
|
assert!(context.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("Expected Start variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_progress_creation() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-999".to_string(),
|
||||||
|
position_ticks: 30_000_000,
|
||||||
|
is_paused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
|
||||||
|
assert_eq!(item_id, "item-999");
|
||||||
|
assert_eq!(position_ticks, 30_000_000);
|
||||||
|
assert!(is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Progress variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_progress_playing() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-555".to_string(),
|
||||||
|
position_ticks: 45_000_000,
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Progress { is_paused, .. } = operation {
|
||||||
|
assert!(!is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Progress variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_stopped_creation() {
|
||||||
|
let operation = PlaybackOperation::Stopped {
|
||||||
|
item_id: "item-111".to_string(),
|
||||||
|
position_ticks: 120_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
|
||||||
|
assert_eq!(item_id, "item-111");
|
||||||
|
assert_eq!(position_ticks, 120_000_000);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Stopped variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_mark_played_creation() {
|
||||||
|
let operation = PlaybackOperation::MarkPlayed {
|
||||||
|
item_id: "item-222".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::MarkPlayed { item_id } = operation {
|
||||||
|
assert_eq!(item_id, "item-222");
|
||||||
|
} else {
|
||||||
|
panic!("Expected MarkPlayed variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_with_series() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "series".to_string(),
|
||||||
|
context_id: Some("series-789".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(context.context_type, "series");
|
||||||
|
assert_eq!(context.context_id, Some("series-789".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_without_id() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "folder".to_string(),
|
||||||
|
context_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(context.context_type, "folder");
|
||||||
|
assert!(context.context_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_clone() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "container".to_string(),
|
||||||
|
context_id: Some("container-123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = context.clone();
|
||||||
|
assert_eq!(cloned.context_type, "container");
|
||||||
|
assert_eq!(cloned.context_id, Some("container-123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_seconds_to_ticks_conversion() {
|
||||||
|
assert_eq!(seconds_to_ticks(0.0), 0);
|
||||||
|
assert_eq!(seconds_to_ticks(1.0), 10_000_000);
|
||||||
|
assert_eq!(seconds_to_ticks(1.5), 15_000_000);
|
||||||
|
assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_reporter_wrapper_structure() {
|
||||||
|
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
|
||||||
|
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_debug_trait() {
|
||||||
|
// Verify Debug trait is implemented for operations
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-1".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", operation);
|
||||||
|
assert!(debug_str.contains("Start"));
|
||||||
|
assert!(debug_str.contains("item-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_clone() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-clone".to_string(),
|
||||||
|
position_ticks: 50_000_000,
|
||||||
|
is_paused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = operation.clone();
|
||||||
|
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
|
||||||
|
assert_eq!(item_id, "item-clone");
|
||||||
|
assert!(is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Clone failed to preserve variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_all_variants() {
|
||||||
|
// Test that all operation variants can be created and matched
|
||||||
|
let start_op = PlaybackOperation::Start {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(start_op, PlaybackOperation::Start { .. }));
|
||||||
|
|
||||||
|
let progress_op = PlaybackOperation::Progress {
|
||||||
|
item_id: "i2".to_string(),
|
||||||
|
position_ticks: 100,
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
|
||||||
|
|
||||||
|
let stopped_op = PlaybackOperation::Stopped {
|
||||||
|
item_id: "i3".to_string(),
|
||||||
|
position_ticks: 200,
|
||||||
|
};
|
||||||
|
assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
|
||||||
|
|
||||||
|
let played_op = PlaybackOperation::MarkPlayed {
|
||||||
|
item_id: "i4".to_string(),
|
||||||
|
};
|
||||||
|
assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
//! Queue manipulation commands (add / remove / move / skip).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
check_for_local_download, create_media_item, get_player_status, get_queue_status,
|
||||||
|
DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus,
|
||||||
|
};
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
||||||
|
use crate::repository::types::{ImageOptions, ImageType};
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
/// Request to add items to queue
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddToQueueRequest {
|
||||||
|
pub items: Vec<PlayItemRequest>,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to add a track by ID - backend fetches metadata
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddTrackByIdRequest {
|
||||||
|
pub track_id: String,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to add multiple tracks by IDs - backend fetches metadata
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddTracksByIdsRequest {
|
||||||
|
pub track_ids: Vec<String>,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_to_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
request: AddToQueueRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create media items first (without holding any locks during await)
|
||||||
|
let mut items: Vec<MediaItem> = Vec::new();
|
||||||
|
for req in request.items {
|
||||||
|
items.push(create_media_item(req, Some(&db)).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now add to queue
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(items, position);
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_remove_from_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.remove(index);
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_move_in_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
from_index: usize,
|
||||||
|
to_index: usize,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !queue_lock.move_item(from_index, to_index) {
|
||||||
|
return Err("Invalid indices for move operation".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a track to queue by ID - backend fetches metadata and constructs URLs
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_track_by_id(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository_manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
request: AddTrackByIdRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
info!("player_add_track_by_id called: track_id={}, position={}",
|
||||||
|
request.track_id, request.position);
|
||||||
|
|
||||||
|
// Get repository (hybrid - supports offline/online)
|
||||||
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
|
// Fetch track metadata via repository
|
||||||
|
info!("Fetching metadata for track {} via repository", request.track_id);
|
||||||
|
let track = repository.get_item(&request.track_id).await
|
||||||
|
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||||
|
|
||||||
|
// Check for local download first
|
||||||
|
let local_path = check_for_local_download(&db, &request.track_id).await?;
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
info!("Using local download for track {}", request.track_id);
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(track.id.clone()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get stream URL from repository (works online/offline)
|
||||||
|
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||||
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id: track.id.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
|
let media_item = MediaItem {
|
||||||
|
id: track.id.clone(),
|
||||||
|
title: track.name.clone(),
|
||||||
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
|
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
|
album: track.album_name.clone(),
|
||||||
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
|
album_id: track.album_id.clone(),
|
||||||
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
|
playlist_id: None,
|
||||||
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
|
track.album_id.as_ref().map(|album_id| {
|
||||||
|
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||||
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source,
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to queue at specified position
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(vec![media_item], position);
|
||||||
|
|
||||||
|
let result = get_queue_status(&controller);
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
info!("Successfully added track {} to queue", request.track_id);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_tracks_by_ids(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository_manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
request: AddTracksByIdsRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
||||||
|
request.track_ids.len(), request.position);
|
||||||
|
|
||||||
|
// Get repository (hybrid - supports offline/online)
|
||||||
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
|
// Fetch metadata and build MediaItems for all tracks
|
||||||
|
let mut media_items = Vec::new();
|
||||||
|
for track_id in &request.track_ids {
|
||||||
|
info!("Fetching metadata for track {} via repository", track_id);
|
||||||
|
let track = repository.get_item(track_id).await
|
||||||
|
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||||
|
|
||||||
|
// Check for local download first
|
||||||
|
let local_path = check_for_local_download(&db, track_id).await?;
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
info!("Using local download for track {}", track_id);
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(track.id.clone()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get stream URL from repository (works online/offline)
|
||||||
|
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||||
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id: track.id.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
|
let media_item = MediaItem {
|
||||||
|
id: track.id.clone(),
|
||||||
|
title: track.name.clone(),
|
||||||
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
|
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
|
album: track.album_name.clone(),
|
||||||
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
|
album_id: track.album_id.clone(),
|
||||||
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
|
playlist_id: None,
|
||||||
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
|
track.album_id.as_ref().map(|album_id| {
|
||||||
|
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||||
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source,
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
media_items.push(media_item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue at specified position
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(media_items, position);
|
||||||
|
|
||||||
|
let result = get_queue_status(&controller);
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
info!("Successfully added {} tracks to queue", request.track_ids.len());
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_skip_to(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<PlayerStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
|
// Skip to the index and get the item to play
|
||||||
|
let item = {
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play the item without modifying the queue
|
||||||
|
controller.load_and_play(&item).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Emit queue changed event
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(get_player_status(&controller))
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! Remote Jellyfin session control commands (casting to another device).
|
||||||
|
//!
|
||||||
|
//! These thin command adapters forward control actions to the active Jellyfin
|
||||||
|
//! session via the player's configured `JellyfinClient`.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::PlayerStateWrapper;
|
||||||
|
|
||||||
|
/// Play items on a remote Jellyfin session (casting)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_play_on_session(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
item_ids: Vec<String>,
|
||||||
|
start_index: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
|
||||||
|
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
||||||
|
client.play_on_session(session_id, item_ids, start_index, None).await?;
|
||||||
|
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
||||||
|
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a playback command to a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_send_command(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
command: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.send_session_command(session_id, &command).await?;
|
||||||
|
log::info!("[RemoteSession] Command sent successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_seek(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_seek(session_id, position_ticks).await?;
|
||||||
|
log::info!("[RemoteSession] Seek successful");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_set_volume(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
volume: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_set_volume(session_id, volume).await?;
|
||||||
|
log::info!("[RemoteSession] Volume set successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_toggle_mute(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Toggling mute on session {}", session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_toggle_mute(session_id).await?;
|
||||||
|
log::info!("[RemoteSession] Mute toggled successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Media session state commands.
|
||||||
|
//!
|
||||||
|
//! Read and dismiss the current media session (the Now Playing surface backing
|
||||||
|
//! lockscreen/notification controls).
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{MediaSessionManagerWrapper, PlayerStateWrapper};
|
||||||
|
use crate::player::PlayerStatusEvent;
|
||||||
|
|
||||||
|
/// Get the current media session state
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_session(
|
||||||
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
) -> Result<crate::player::session::MediaSessionType, String> {
|
||||||
|
let manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(manager.current().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss the current media session (returns to Idle)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_dismiss_session(
|
||||||
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[Session] Dismissing current media session");
|
||||||
|
|
||||||
|
// Dismiss the session
|
||||||
|
{
|
||||||
|
let mut manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit session changed event
|
||||||
|
if let Some(emitter) = player.0.lock().await.event_emitter() {
|
||||||
|
let manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
emitter.emit(PlayerStatusEvent::SessionChanged {
|
||||||
|
session: manager.current().clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
//! Audio and video playback settings commands.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||||
|
use crate::player::AutoplaySettings;
|
||||||
|
use crate::settings::{AudioSettings, VideoSettings};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_set_audio_settings(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
settings: AudioSettings,
|
||||||
|
) -> Result<AudioSettings, String> {
|
||||||
|
let mut controller = player.0.lock().await;
|
||||||
|
controller
|
||||||
|
.set_audio_settings(&settings)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(controller.audio_settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_audio_settings(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<AudioSettings, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
Ok(controller.audio_settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_set_video_settings(
|
||||||
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
settings: VideoSettings,
|
||||||
|
) -> Result<VideoSettings, String> {
|
||||||
|
let validated = settings.with_countdown_clamped();
|
||||||
|
{
|
||||||
|
let mut current = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
*current = validated.clone();
|
||||||
|
} // Drop MutexGuard before await
|
||||||
|
|
||||||
|
// Sync to PlayerController's autoplay settings so on_playback_ended() uses current values
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.set_autoplay_settings(AutoplaySettings {
|
||||||
|
enabled: validated.auto_play_next_episode,
|
||||||
|
countdown_seconds: validated.auto_play_countdown_seconds,
|
||||||
|
max_episodes: validated.auto_play_max_episodes,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_video_settings(
|
||||||
|
video_settings: State<'_, VideoSettingsWrapper>,
|
||||||
|
) -> Result<VideoSettings, String> {
|
||||||
|
let current = video_settings.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(current.clone())
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
//! Sleep-timer and autoplay commands.
|
||||||
|
//!
|
||||||
|
//! Thin command adapters over `PlayerController`'s sleep-timer and autoplay
|
||||||
|
//! logic, plus persistence of autoplay settings to the database.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
create_media_item, get_player_status, DatabaseWrapper, PlayItemRequest, PlayerStatus,
|
||||||
|
PlayerStateWrapper,
|
||||||
|
};
|
||||||
|
use crate::player::{AutoplaySettings, SleepTimerMode, SleepTimerState};
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
// ===== Sleep Timer Commands =====
|
||||||
|
|
||||||
|
/// Set sleep timer mode
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_set_sleep_timer(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
mode: SleepTimerMode,
|
||||||
|
) -> Result<SleepTimerState, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.set_sleep_timer(mode);
|
||||||
|
Ok(controller.sleep_timer_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel sleep timer
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_cancel_sleep_timer(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<SleepTimerState, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.cancel_sleep_timer();
|
||||||
|
Ok(controller.sleep_timer_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current sleep timer state
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_sleep_timer(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<SleepTimerState, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
Ok(controller.sleep_timer_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Autoplay Commands =====
|
||||||
|
|
||||||
|
/// Get autoplay settings
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_autoplay_settings(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<AutoplaySettings, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
Ok(controller.autoplay_settings())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set autoplay settings and persist to database
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_set_autoplay_settings(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
settings: AutoplaySettings,
|
||||||
|
) -> Result<AutoplaySettings, String> {
|
||||||
|
let validated = settings.with_validated_countdown();
|
||||||
|
|
||||||
|
// Set in controller
|
||||||
|
{
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.set_autoplay_settings(validated.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to database
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO user_player_settings (user_id, autoplay_next_episode, autoplay_countdown_seconds, autoplay_max_episodes, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
autoplay_next_episode = excluded.autoplay_next_episode,
|
||||||
|
autoplay_countdown_seconds = excluded.autoplay_countdown_seconds,
|
||||||
|
autoplay_max_episodes = excluded.autoplay_max_episodes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id),
|
||||||
|
QueryParam::Int(if validated.enabled { 1 } else { 0 }),
|
||||||
|
QueryParam::Int(validated.countdown_seconds as i32),
|
||||||
|
QueryParam::Int(validated.max_episodes as i32),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(validated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel active autoplay countdown
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_cancel_autoplay_countdown(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.cancel_autoplay_countdown();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play next episode (user confirmed from popup)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_play_next_episode(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
item: PlayItemRequest,
|
||||||
|
) -> Result<PlayerStatus, String> {
|
||||||
|
// Convert request to MediaItem
|
||||||
|
let media_item = create_media_item(item, Some(&db)).await?;
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.play_item(media_item).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(get_player_status(&controller))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle playback ended event - triggers autoplay decision logic
|
||||||
|
/// This is called from:
|
||||||
|
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||||
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
|
/// - Android JNI callback also triggers this logic directly
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_on_playback_ended(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
repository_manager: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
||||||
|
item_id: Option<String>,
|
||||||
|
repository_handle: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use crate::player::autoplay::AutoplayDecision;
|
||||||
|
use crate::player::PlayerStatusEvent;
|
||||||
|
|
||||||
|
let controller_arc = player.0.clone();
|
||||||
|
|
||||||
|
// Run autoplay decision logic
|
||||||
|
// If item_id is provided (HTML5 video case), use the video-specific path
|
||||||
|
// that bypasses the backend queue and stale end_reason
|
||||||
|
let decision = {
|
||||||
|
let controller = controller_arc.lock().await;
|
||||||
|
if let Some(ref id) = item_id {
|
||||||
|
// Video path: need repository to look up episode info
|
||||||
|
let repo = repository_handle
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|handle| repository_manager.0.get(handle));
|
||||||
|
if let Some(repo) = repo {
|
||||||
|
controller.on_video_playback_ended(id, repo).await?
|
||||||
|
} else {
|
||||||
|
log::warn!("[Autoplay] No repository available for video autoplay (itemId: {})", id);
|
||||||
|
AutoplayDecision::Stop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
controller.on_playback_ended().await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle the decision
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::Stop => {
|
||||||
|
log::debug!("[Autoplay] Decision: Stop playback");
|
||||||
|
let controller = controller_arc.lock().await;
|
||||||
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
|
// Emit StateChanged to idle to clear the current media from mini player
|
||||||
|
// Note: Do NOT emit PlaybackEnded here - it would cause an infinite loop
|
||||||
|
// (frontend receives PlaybackEnded → calls player_on_playback_ended → Stop → PlaybackEnded → ...)
|
||||||
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
|
state: "idle".to_string(),
|
||||||
|
media_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AutoplayDecision::AdvanceToNext => {
|
||||||
|
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||||
|
// Advance to next track in queue
|
||||||
|
let controller = controller_arc.lock().await;
|
||||||
|
if let Err(e) = controller.next() {
|
||||||
|
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
||||||
|
// Emit PlaybackEnded event on error
|
||||||
|
if let Some(emitter) = controller.event_emitter() {
|
||||||
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Emit queue changed event so frontend updates UI with new current track
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AutoplayDecision::ShowNextEpisodePopup {
|
||||||
|
current_episode,
|
||||||
|
next_episode,
|
||||||
|
countdown_seconds,
|
||||||
|
auto_advance,
|
||||||
|
} => {
|
||||||
|
log::info!(
|
||||||
|
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
||||||
|
countdown_seconds,
|
||||||
|
auto_advance
|
||||||
|
);
|
||||||
|
|
||||||
|
// Emit popup event to frontend
|
||||||
|
if let Some(emitter) = controller_arc.lock().await.event_emitter() {
|
||||||
|
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
||||||
|
current_episode: current_episode.clone(),
|
||||||
|
next_episode: next_episode.clone(),
|
||||||
|
countdown_seconds,
|
||||||
|
auto_advance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start countdown if auto_advance enabled
|
||||||
|
if auto_advance {
|
||||||
|
controller_arc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Tauri commands for playlist management
|
||||||
|
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-014 | JA-019, JA-020
|
||||||
|
|
||||||
|
use log::debug;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::repository::{MediaRepository, types::*};
|
||||||
|
use super::repository::RepositoryManagerWrapper;
|
||||||
|
|
||||||
|
/// Create a new playlist
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_create(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
name: String,
|
||||||
|
item_ids: Option<Vec<String>>,
|
||||||
|
) -> Result<PlaylistCreatedResult, String> {
|
||||||
|
debug!("[PLAYLIST] create called: name={}", name);
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
let ids = item_ids.unwrap_or_default();
|
||||||
|
repo.as_ref().create_playlist(&name, &ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a playlist
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_delete(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
debug!("[PLAYLIST] delete called: id={}", playlist_id);
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().delete_playlist(&playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename a playlist
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_rename(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
debug!("[PLAYLIST] rename called: id={}, name={}", playlist_id, name);
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().rename_playlist(&playlist_id, &name)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get playlist items with PlaylistItemId
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_get_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
) -> Result<Vec<PlaylistEntry>, String> {
|
||||||
|
debug!("[PLAYLIST] get_items called: id={}", playlist_id);
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_playlist_items(&playlist_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add items to a playlist
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_add_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
item_ids: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
debug!("[PLAYLIST] add_items called: id={}, count={}", playlist_id, item_ids.len());
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().add_to_playlist(&playlist_id, &item_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove items from a playlist (uses PlaylistItemId entry IDs, NOT media item IDs)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_remove_items(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
entry_ids: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
debug!("[PLAYLIST] remove_items called: id={}, count={}", playlist_id, entry_ids.len());
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().remove_from_playlist(&playlist_id, &entry_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a playlist item to a new position
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playlist_move_item(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
playlist_id: String,
|
||||||
|
item_id: String,
|
||||||
|
new_index: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
debug!("[PLAYLIST] move_item called: playlist={}, item={}, index={}", playlist_id, item_id, new_index);
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().move_playlist_item(&playlist_id, &item_id, new_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// Tauri commands for repository access
|
//! Tauri commands for repository access
|
||||||
// Uses handle-based system: UUID -> Arc<HybridRepository>
|
//! Uses handle-based system: UUID -> Arc<HybridRepository>
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info, warn};
|
||||||
use tauri::State;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::jellyfin::HttpClient;
|
use crate::jellyfin::HttpClient;
|
||||||
@@ -24,17 +28,17 @@ impl RepositoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create(&self, handle: String, repository: HybridRepository) {
|
pub fn create(&self, handle: String, repository: HybridRepository) {
|
||||||
let mut repos = self.repositories.lock().unwrap();
|
let mut repos = self.repositories.lock_safe();
|
||||||
repos.insert(handle, Arc::new(repository));
|
repos.insert(handle, Arc::new(repository));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
|
||||||
let repos = self.repositories.lock().unwrap();
|
let repos = self.repositories.lock_safe();
|
||||||
repos.get(handle).cloned()
|
repos.get(handle).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn destroy(&self, handle: &str) {
|
pub fn destroy(&self, handle: &str) {
|
||||||
let mut repos = self.repositories.lock().unwrap();
|
let mut repos = self.repositories.lock_safe();
|
||||||
repos.remove(handle);
|
repos.remove(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,9 +49,11 @@ pub struct RepositoryManagerWrapper(pub RepositoryManager);
|
|||||||
/// Create a new repository instance
|
/// Create a new repository instance
|
||||||
/// Returns a handle (UUID) for accessing the repository
|
/// Returns a handle (UUID) for accessing the repository
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
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,
|
||||||
@@ -64,9 +70,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
|
||||||
@@ -105,6 +120,7 @@ pub async fn repository_create(
|
|||||||
|
|
||||||
/// Destroy a repository instance
|
/// Destroy a repository instance
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_destroy(
|
pub async fn repository_destroy(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -115,6 +131,7 @@ pub async fn repository_destroy(
|
|||||||
|
|
||||||
/// Get libraries
|
/// Get libraries
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_libraries(
|
pub async fn repository_get_libraries(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -135,6 +152,7 @@ pub async fn repository_get_libraries(
|
|||||||
|
|
||||||
/// Get items in a container (library, folder, album, etc.)
|
/// Get items in a container (library, folder, album, etc.)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_items(
|
pub async fn repository_get_items(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -149,6 +167,7 @@ pub async fn repository_get_items(
|
|||||||
|
|
||||||
/// Get a single item by ID
|
/// Get a single item by ID
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_item(
|
pub async fn repository_get_item(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -162,6 +181,7 @@ pub async fn repository_get_item(
|
|||||||
|
|
||||||
/// Get latest items in a library
|
/// Get latest items in a library
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_latest_items(
|
pub async fn repository_get_latest_items(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -176,6 +196,7 @@ pub async fn repository_get_latest_items(
|
|||||||
|
|
||||||
/// Get resume items (continue watching/listening)
|
/// Get resume items (continue watching/listening)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_resume_items(
|
pub async fn repository_get_resume_items(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -198,6 +219,7 @@ pub async fn repository_get_resume_items(
|
|||||||
|
|
||||||
/// Get next up episodes
|
/// Get next up episodes
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_next_up_episodes(
|
pub async fn repository_get_next_up_episodes(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -212,6 +234,7 @@ pub async fn repository_get_next_up_episodes(
|
|||||||
|
|
||||||
/// Get recently played audio
|
/// Get recently played audio
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_recently_played_audio(
|
pub async fn repository_get_recently_played_audio(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -225,6 +248,7 @@ pub async fn repository_get_recently_played_audio(
|
|||||||
|
|
||||||
/// Get resume movies
|
/// Get resume movies
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_resume_movies(
|
pub async fn repository_get_resume_movies(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -236,8 +260,24 @@ pub async fn repository_get_resume_movies(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get albums the user hasn't listened to recently ("rediscover")
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn repository_get_rediscover_albums(
|
||||||
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
handle: String,
|
||||||
|
parent_id: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, String> {
|
||||||
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
repo.as_ref().get_rediscover_albums(parent_id.as_deref(), limit)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("{:?}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Get genres for a library
|
/// Get genres for a library
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_genres(
|
pub async fn repository_get_genres(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -249,22 +289,78 @@ pub async fn repository_get_genres(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tauri event name carrying the merged (cache + server) search results.
|
||||||
|
pub const SEARCH_EVENT_NAME: &str = "search-event";
|
||||||
|
|
||||||
|
/// Payload for the deferred, merged search results pushed to the frontend.
|
||||||
|
///
|
||||||
|
/// `request_id` matches the value the frontend passed to `repository_search`,
|
||||||
|
/// letting it discard updates from queries that have since been superseded.
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SearchUpdateEvent {
|
||||||
|
pub request_id: u32,
|
||||||
|
pub result: SearchResult,
|
||||||
|
}
|
||||||
|
|
||||||
/// Search for items
|
/// Search for items
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_search(
|
pub async fn repository_search(
|
||||||
|
app: AppHandle,
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
query: String,
|
query: String,
|
||||||
options: Option<SearchOptions>,
|
options: Option<SearchOptions>,
|
||||||
|
request_id: u32,
|
||||||
) -> Result<SearchResult, String> {
|
) -> Result<SearchResult, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
repo.as_ref().search(&query, options)
|
|
||||||
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||||
|
// UI can render immediately while the server is still being queried.
|
||||||
|
let cache_result = repo
|
||||||
|
.search_cache_only(&query, options.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.unwrap_or_else(|e| {
|
||||||
|
debug!("[Search] Cache search miss/timeout: {:?}", e);
|
||||||
|
SearchResult {
|
||||||
|
items: Vec::new(),
|
||||||
|
total_record_count: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 2: query the live server in the background, merge with the cache,
|
||||||
|
// and push the union to the frontend via a `search-event`. Tagged with
|
||||||
|
// `request_id` so the frontend can discard results from superseded queries.
|
||||||
|
let repo_bg = repo.clone();
|
||||||
|
let cache_for_merge = cache_result.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match repo_bg.search_server_only(&query, options).await {
|
||||||
|
Ok(server_result) => {
|
||||||
|
let merged =
|
||||||
|
HybridRepository::merge_search_results(cache_for_merge, server_result);
|
||||||
|
let event = SearchUpdateEvent {
|
||||||
|
request_id,
|
||||||
|
result: merged,
|
||||||
|
};
|
||||||
|
if let Err(e) = app.emit(SEARCH_EVENT_NAME, &event) {
|
||||||
|
error!("[Search] Failed to emit search update: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Server failed — the cache results are already on screen, so
|
||||||
|
// just log. (Offline / unreachable server falls here.)
|
||||||
|
warn!("[Search] Server search failed, keeping cache results: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(cache_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get playback info for an item
|
/// Get playback info for an item
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_playback_info(
|
pub async fn repository_get_playback_info(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -278,6 +374,7 @@ pub async fn repository_get_playback_info(
|
|||||||
|
|
||||||
/// Get video stream URL with optional seeking support
|
/// Get video stream URL with optional seeking support
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_video_stream_url(
|
pub async fn repository_get_video_stream_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -300,6 +397,7 @@ pub async fn repository_get_video_stream_url(
|
|||||||
|
|
||||||
/// Get audio stream URL for a track
|
/// Get audio stream URL for a track
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_audio_stream_url(
|
pub async fn repository_get_audio_stream_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -314,6 +412,7 @@ pub async fn repository_get_audio_stream_url(
|
|||||||
|
|
||||||
/// Report playback start
|
/// Report playback start
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_start(
|
pub async fn repository_report_playback_start(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -328,6 +427,7 @@ pub async fn repository_report_playback_start(
|
|||||||
|
|
||||||
/// Report playback progress
|
/// Report playback progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_progress(
|
pub async fn repository_report_playback_progress(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -342,6 +442,7 @@ pub async fn repository_report_playback_progress(
|
|||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_stopped(
|
pub async fn repository_report_playback_stopped(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -356,6 +457,7 @@ pub async fn repository_report_playback_stopped(
|
|||||||
|
|
||||||
/// Get image URL for an item
|
/// Get image URL for an item
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn repository_get_image_url(
|
pub fn repository_get_image_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -369,6 +471,8 @@ pub fn repository_get_image_url(
|
|||||||
|
|
||||||
/// Get subtitle URL for a media item
|
/// Get subtitle URL for a media item
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn repository_get_subtitle_url(
|
pub fn repository_get_subtitle_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -383,6 +487,8 @@ pub fn repository_get_subtitle_url(
|
|||||||
|
|
||||||
/// Get video download URL with quality preset
|
/// Get video download URL with quality preset
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn repository_get_video_download_url(
|
pub fn repository_get_video_download_url(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -396,6 +502,7 @@ pub fn repository_get_video_download_url(
|
|||||||
|
|
||||||
/// Mark an item as favorite
|
/// Mark an item as favorite
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_mark_favorite(
|
pub async fn repository_mark_favorite(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -409,6 +516,7 @@ pub async fn repository_mark_favorite(
|
|||||||
|
|
||||||
/// Unmark an item as favorite
|
/// Unmark an item as favorite
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_unmark_favorite(
|
pub async fn repository_unmark_favorite(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -422,6 +530,7 @@ pub async fn repository_unmark_favorite(
|
|||||||
|
|
||||||
/// Get person details
|
/// Get person details
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_person(
|
pub async fn repository_get_person(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -435,6 +544,7 @@ pub async fn repository_get_person(
|
|||||||
|
|
||||||
/// Get items by person (actor, director, etc.)
|
/// Get items by person (actor, director, etc.)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_items_by_person(
|
pub async fn repository_get_items_by_person(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -449,6 +559,7 @@ pub async fn repository_get_items_by_person(
|
|||||||
|
|
||||||
/// Get similar/related items for a media item
|
/// Get similar/related items for a media item
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn repository_get_similar_items(
|
pub async fn repository_get_similar_items(
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
@@ -460,3 +571,122 @@ pub async fn repository_get_similar_items(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_creation() {
|
||||||
|
let manager = RepositoryManager::new();
|
||||||
|
// A freshly created manager holds no repositories
|
||||||
|
assert!(manager.get("any-handle").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_wrapper_structure() {
|
||||||
|
let manager = RepositoryManager::new();
|
||||||
|
let wrapper = RepositoryManagerWrapper(manager);
|
||||||
|
// The wrapper exposes the underlying manager, which starts empty
|
||||||
|
assert!(wrapper.0.get("any-handle").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_get_nonexistent() {
|
||||||
|
let manager = RepositoryManager::new();
|
||||||
|
// Getting a non-existent repository should return None
|
||||||
|
let result = manager.get("nonexistent-handle");
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_handle_generation() {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let handle = format!("{}", uuid);
|
||||||
|
// UUID should convert to a non-empty string
|
||||||
|
assert!(!handle.is_empty());
|
||||||
|
assert!(handle.len() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_handles_are_unique() {
|
||||||
|
let handle1 = format!("{}", Uuid::new_v4());
|
||||||
|
let handle2 = format!("{}", Uuid::new_v4());
|
||||||
|
// Two generated UUIDs should be different
|
||||||
|
assert_ne!(handle1, handle2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_uuid_handle_format() {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let handle = format!("{}", uuid);
|
||||||
|
// UUID should have standard format with hyphens
|
||||||
|
let parts: Vec<&str> = handle.split('-').collect();
|
||||||
|
assert_eq!(parts.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_destroy_nonexistent() {
|
||||||
|
let manager = RepositoryManager::new();
|
||||||
|
// Destroying a non-existent repository should not panic
|
||||||
|
manager.destroy("nonexistent-handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_is_send_sync() {
|
||||||
|
// Verify RepositoryManager can be used in async contexts
|
||||||
|
fn is_send_sync<T: Send + Sync>() {}
|
||||||
|
is_send_sync::<RepositoryManager>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_wrapper_is_send_sync() {
|
||||||
|
// Verify RepositoryManagerWrapper is Send + Sync
|
||||||
|
fn is_send_sync<T: Send + Sync>() {}
|
||||||
|
is_send_sync::<RepositoryManagerWrapper>();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_manager_instances() {
|
||||||
|
let manager1 = RepositoryManager::new();
|
||||||
|
let manager2 = RepositoryManager::new();
|
||||||
|
|
||||||
|
// Multiple manager instances should be independent
|
||||||
|
let handle1_nonexistent = manager1.get("test");
|
||||||
|
let handle2_nonexistent = manager2.get("test");
|
||||||
|
|
||||||
|
assert!(handle1_nonexistent.is_none());
|
||||||
|
assert!(handle2_nonexistent.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handle_string_properties() {
|
||||||
|
let uuid = Uuid::new_v4();
|
||||||
|
let handle = format!("{}", uuid);
|
||||||
|
|
||||||
|
// Handle should be alphanumeric with hyphens
|
||||||
|
for c in handle.chars() {
|
||||||
|
assert!(c.is_alphanumeric() || c == '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_repository_manager_concurrent_access() {
|
||||||
|
let manager = Arc::new(RepositoryManager::new());
|
||||||
|
let mut handles = vec![];
|
||||||
|
|
||||||
|
// Verify manager can be wrapped in Arc for concurrent access
|
||||||
|
for _ in 0..3 {
|
||||||
|
let mgr = Arc::clone(&manager);
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let result = mgr.get("test");
|
||||||
|
assert!(result.is_none());
|
||||||
|
});
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
h.join().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-010 | JA-021 | DR-037
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
use crate::session_poller::{PollingHint, SessionPollerManager};
|
use crate::session_poller::{PollingHint, SessionPollerManager};
|
||||||
@@ -8,6 +10,7 @@ pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
|
|||||||
|
|
||||||
/// Set polling frequency hint based on UI state
|
/// Set polling frequency hint based on UI state
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn sessions_set_polling_hint(
|
pub fn sessions_set_polling_hint(
|
||||||
poller: State<'_, SessionPollerWrapper>,
|
poller: State<'_, SessionPollerWrapper>,
|
||||||
hint: String,
|
hint: String,
|
||||||
@@ -25,8 +28,75 @@ pub fn sessions_set_polling_hint(
|
|||||||
|
|
||||||
/// Manually trigger a session poll (for refresh button)
|
/// Manually trigger a session poll (for refresh button)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sessions_poll_now(
|
pub async fn sessions_poll_now(
|
||||||
poller: State<'_, SessionPollerWrapper>,
|
poller: State<'_, SessionPollerWrapper>,
|
||||||
) -> Result<Vec<SessionInfo>, String> {
|
) -> Result<Vec<SessionInfo>, String> {
|
||||||
poller.0.poll_now().await
|
poller.0.poll_now().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_polling_hint_parsing() {
|
||||||
|
// Test valid hints
|
||||||
|
assert_eq!(
|
||||||
|
match "cast_active" {
|
||||||
|
"cast_active" => Some(PollingHint::CastActive),
|
||||||
|
"cast_discovery" => Some(PollingHint::CastDiscovery),
|
||||||
|
"normal" => Some(PollingHint::Normal),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Some(PollingHint::CastActive)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
match "cast_discovery" {
|
||||||
|
"cast_active" => Some(PollingHint::CastActive),
|
||||||
|
"cast_discovery" => Some(PollingHint::CastDiscovery),
|
||||||
|
"normal" => Some(PollingHint::Normal),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Some(PollingHint::CastDiscovery)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
match "normal" {
|
||||||
|
"cast_active" => Some(PollingHint::CastActive),
|
||||||
|
"cast_discovery" => Some(PollingHint::CastDiscovery),
|
||||||
|
"normal" => Some(PollingHint::Normal),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Some(PollingHint::Normal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_polling_hint() {
|
||||||
|
// Test invalid hint
|
||||||
|
let result = match "invalid" {
|
||||||
|
"cast_active" => Ok(PollingHint::CastActive),
|
||||||
|
"cast_discovery" => Ok(PollingHint::CastDiscovery),
|
||||||
|
"normal" => Ok(PollingHint::Normal),
|
||||||
|
_ => Err("Invalid polling hint"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_poller_wrapper_structure() {
|
||||||
|
// Test that wrapper type structure is correct
|
||||||
|
assert_eq!(std::mem::size_of::<SessionPollerWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_polling_hints_exist() {
|
||||||
|
// Verify polling hint variants exist
|
||||||
|
let _ = PollingHint::CastActive;
|
||||||
|
let _ = PollingHint::CastDiscovery;
|
||||||
|
let _ = PollingHint::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
|||||||
|
//! Person/cast metadata cache commands.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
|
||||||
|
/// Cached person info returned to frontend
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CachedPerson {
|
||||||
|
pub id: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub overview: Option<String>,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
pub premiere_date: Option<String>,
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Item-person association for caching
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CachedItemPerson {
|
||||||
|
pub item_id: String,
|
||||||
|
pub person_id: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub person_type: String,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a person to the cache
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_save_person(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
person: CachedPerson,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT OR REPLACE INTO people (
|
||||||
|
id, server_id, name, overview, primary_image_tag,
|
||||||
|
premiere_date, end_date, synced_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(person.id),
|
||||||
|
QueryParam::String(person.server_id),
|
||||||
|
QueryParam::String(person.name),
|
||||||
|
person.overview.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
person.primary_image_tag.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
person.premiere_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
person.end_date.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a cached person by ID
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_get_person(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
person_id: String,
|
||||||
|
) -> Result<Option<CachedPerson>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT id, server_id, name, overview, primary_image_tag, premiere_date, end_date
|
||||||
|
FROM people WHERE id = ?",
|
||||||
|
vec![QueryParam::String(person_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = db_service
|
||||||
|
.query_optional(query, |row| {
|
||||||
|
Ok(CachedPerson {
|
||||||
|
id: row.get(0)?,
|
||||||
|
server_id: row.get(1)?,
|
||||||
|
name: row.get(2)?,
|
||||||
|
overview: row.get(3)?,
|
||||||
|
primary_image_tag: row.get(4)?,
|
||||||
|
premiere_date: row.get(5)?,
|
||||||
|
end_date: row.get(6)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save item-person associations (batch)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_save_item_people(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
associations: Vec<CachedItemPerson>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone associations for the closure
|
||||||
|
let associations_clone = associations.clone();
|
||||||
|
|
||||||
|
// Use transaction for batch insert
|
||||||
|
db_service.transaction(move |tx| {
|
||||||
|
for assoc in &associations_clone {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT OR REPLACE INTO item_people (
|
||||||
|
item_id, person_id, server_id, person_type, role, sort_order, synced_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(assoc.item_id.clone()),
|
||||||
|
QueryParam::String(assoc.person_id.clone()),
|
||||||
|
QueryParam::String(assoc.server_id.clone()),
|
||||||
|
QueryParam::String(assoc.person_type.clone()),
|
||||||
|
assoc.role.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
QueryParam::Int(assoc.sort_order),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
tx.execute(query)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get people for an item (with person details joined)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_get_item_people(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<Vec<CachedItemPerson>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT ip.item_id, ip.person_id, ip.server_id, ip.person_type, ip.role, ip.sort_order
|
||||||
|
FROM item_people ip
|
||||||
|
WHERE ip.item_id = ?
|
||||||
|
ORDER BY ip.sort_order ASC",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let people = db_service
|
||||||
|
.query_many(query, |row| {
|
||||||
|
Ok(CachedItemPerson {
|
||||||
|
item_id: row.get(0)?,
|
||||||
|
person_id: row.get(1)?,
|
||||||
|
server_id: row.get(2)?,
|
||||||
|
person_type: row.get(3)?,
|
||||||
|
role: row.get(4)?,
|
||||||
|
sort_order: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(people)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Per-series preferred audio track commands.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
|
||||||
|
/// Audio track preference for a series
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SeriesAudioPreference {
|
||||||
|
pub series_id: String,
|
||||||
|
pub audio_track_display_title: Option<String>,
|
||||||
|
pub audio_track_language: Option<String>,
|
||||||
|
pub audio_track_index: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save user's preferred audio track for a series
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_save_series_audio_preference(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
series_id: String,
|
||||||
|
server_id: String,
|
||||||
|
audio_track_display_title: Option<String>,
|
||||||
|
audio_track_language: Option<String>,
|
||||||
|
audio_track_index: Option<i32>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"INSERT INTO series_audio_preferences
|
||||||
|
(user_id, series_id, server_id, audio_track_display_title, audio_track_language, audio_track_index, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT (user_id, series_id, server_id) DO UPDATE SET
|
||||||
|
audio_track_display_title = excluded.audio_track_display_title,
|
||||||
|
audio_track_language = excluded.audio_track_language,
|
||||||
|
audio_track_index = excluded.audio_track_index,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id),
|
||||||
|
QueryParam::String(series_id),
|
||||||
|
QueryParam::String(server_id),
|
||||||
|
audio_track_display_title.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
audio_track_language.map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
audio_track_index.map(|i| QueryParam::Int64(i as i64)).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user's preferred audio track for a series
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn storage_get_series_audio_preference(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
series_id: String,
|
||||||
|
) -> Result<Option<SeriesAudioPreference>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT series_id, audio_track_display_title, audio_track_language, audio_track_index
|
||||||
|
FROM series_audio_preferences
|
||||||
|
WHERE user_id = ? AND series_id = ?",
|
||||||
|
vec![QueryParam::String(user_id), QueryParam::String(series_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let preference = db_service
|
||||||
|
.query_optional(query, |row| {
|
||||||
|
Ok(SeriesAudioPreference {
|
||||||
|
series_id: row.get(0)?,
|
||||||
|
audio_track_display_title: row.get(1)?,
|
||||||
|
audio_track_language: row.get(2)?,
|
||||||
|
audio_track_index: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(preference)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! Thumbnail cache and image-URL commands.
|
||||||
|
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{DatabaseWrapper, ThumbnailCacheWrapper};
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::repository::types::{ImageOptions, ImageType};
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
use crate::thumbnail::{ThumbnailCacheStats, ThumbnailWorker};
|
||||||
|
|
||||||
|
|
||||||
|
/// Get cached thumbnail path, returns None if not cached
|
||||||
|
/// Also updates last_accessed timestamp for LRU tracking
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_get_cached(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
image_type: String,
|
||||||
|
tag: String,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = thumbnail_cache.0
|
||||||
|
.get_cached_path(db_service, &item_id, &image_type, &tag)
|
||||||
|
.await
|
||||||
|
.map(|p| p.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and save a thumbnail to cache
|
||||||
|
/// Returns the local file path on success
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_save(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
image_type: String,
|
||||||
|
tag: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
// Download the image
|
||||||
|
let worker = ThumbnailWorker::new();
|
||||||
|
let data = worker
|
||||||
|
.download_with_retry(&url, 2)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Save to cache
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = thumbnail_cache.0.save_thumbnail(db_service, &item_id, &image_type, &tag, &data, None, None).await?;
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get thumbnail cache statistics
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_get_stats(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
) -> Result<ThumbnailCacheStats, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_size_bytes = thumbnail_cache.0.get_cache_size(db_service.clone()).await;
|
||||||
|
let item_count = thumbnail_cache.0.get_item_count(db_service.clone()).await;
|
||||||
|
let limit_bytes = thumbnail_cache.0.get_limit(db_service).await;
|
||||||
|
|
||||||
|
Ok(ThumbnailCacheStats {
|
||||||
|
total_size_bytes,
|
||||||
|
item_count,
|
||||||
|
limit_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set thumbnail cache storage limit in bytes
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_set_limit(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
limit_bytes: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
thumbnail_cache.0.set_limit(db_service, limit_bytes).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached thumbnails
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_clear_cache(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
thumbnail_cache.0.clear_cache(db_service).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete cached thumbnails for a specific item
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn thumbnail_delete_item(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
item_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
thumbnail_cache.0.delete_item(db_service, &item_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_from_ext(ext: Option<&str>) -> &'static str {
|
||||||
|
match ext {
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Limit concurrent image downloads to avoid saturating the connection pool.
|
||||||
|
/// Without this, rendering a page with hundreds of album cards fires hundreds of
|
||||||
|
/// concurrent HTTP requests, starving API calls and causing timeouts.
|
||||||
|
static IMAGE_DOWNLOAD_SEMAPHORE: OnceLock<Semaphore> = OnceLock::new();
|
||||||
|
|
||||||
|
fn image_semaphore() -> &'static Semaphore {
|
||||||
|
IMAGE_DOWNLOAD_SEMAPHORE.get_or_init(|| Semaphore::new(6))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to get an image URL (with caching)
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetImageRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub image_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_width: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_height: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get image as base64 data URL, caching if not already cached
|
||||||
|
/// This extends the thumbnail system to serve all images through Rust with automatic caching
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn image_get_url(
|
||||||
|
repository_manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
thumbnail_cache: State<'_, ThumbnailCacheWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
request: GetImageRequest,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let tag = request.tag.as_deref().unwrap_or("default");
|
||||||
|
|
||||||
|
// Get database service
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let Some(cached_path) = thumbnail_cache.0.get_cached_path(
|
||||||
|
db_service.clone(),
|
||||||
|
&request.item_id,
|
||||||
|
&request.image_type,
|
||||||
|
tag,
|
||||||
|
).await {
|
||||||
|
let image_data = fs::read(&cached_path)
|
||||||
|
.map_err(|e| format!("Failed to read cached image: {}", e))?;
|
||||||
|
let base64_data = BASE64.encode(&image_data);
|
||||||
|
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||||
|
return Ok(format!("data:{};base64,{}", mime_type, base64_data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached — fetch from server and cache.
|
||||||
|
// Acquire semaphore to limit concurrent downloads (prevents connection pool starvation).
|
||||||
|
let _permit = image_semaphore().acquire().await
|
||||||
|
.map_err(|_| "Image download semaphore closed".to_string())?;
|
||||||
|
|
||||||
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
|
.ok_or_else(|| "Repository not found - user may need to log in".to_string())?;
|
||||||
|
|
||||||
|
let image_type_enum = match request.image_type.as_str() {
|
||||||
|
"Primary" => ImageType::Primary,
|
||||||
|
"Backdrop" => ImageType::Backdrop,
|
||||||
|
"Banner" => ImageType::Banner,
|
||||||
|
"Thumb" => ImageType::Thumb,
|
||||||
|
"Logo" => ImageType::Logo,
|
||||||
|
_ => ImageType::Primary,
|
||||||
|
};
|
||||||
|
|
||||||
|
let options = ImageOptions {
|
||||||
|
max_width: request.max_width,
|
||||||
|
max_height: request.max_height,
|
||||||
|
quality: Some(90),
|
||||||
|
tag: request.tag.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_url = repository.get_image_url(&request.item_id, image_type_enum, Some(options));
|
||||||
|
let image_data = repository.download_bytes(&server_url).await
|
||||||
|
.map_err(|e| format!("Failed to download image: {}", e))?;
|
||||||
|
|
||||||
|
let cached_path = thumbnail_cache.0.save_thumbnail(
|
||||||
|
db_service,
|
||||||
|
&request.item_id,
|
||||||
|
&request.image_type,
|
||||||
|
tag,
|
||||||
|
&image_data,
|
||||||
|
request.max_width.map(|w| w as i32),
|
||||||
|
request.max_height.map(|h| h as i32),
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let base64_data = BASE64.encode(&image_data);
|
||||||
|
let mime_type = mime_from_ext(cached_path.extension().and_then(|s| s.to_str()));
|
||||||
|
Ok(format!("data:{};base64,{}", mime_type, base64_data))
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use super::storage::DatabaseWrapper;
|
|||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
/// Sync queue item returned to frontend
|
/// Sync queue item returned to frontend
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SyncQueueItem {
|
pub struct SyncQueueItem {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -28,6 +28,7 @@ pub struct SyncQueueItem {
|
|||||||
|
|
||||||
/// Queue a mutation for sync to server
|
/// Queue a mutation for sync to server
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_queue_mutation(
|
pub async fn sync_queue_mutation(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -59,6 +60,7 @@ pub async fn sync_queue_mutation(
|
|||||||
|
|
||||||
/// Get all pending sync operations for a user
|
/// Get all pending sync operations for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_get_pending(
|
pub async fn sync_get_pending(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -107,6 +109,7 @@ pub async fn sync_get_pending(
|
|||||||
|
|
||||||
/// Mark a sync operation as in progress
|
/// Mark a sync operation as in progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_mark_processing(
|
pub async fn sync_mark_processing(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -127,6 +130,7 @@ pub async fn sync_mark_processing(
|
|||||||
|
|
||||||
/// Mark a sync operation as completed
|
/// Mark a sync operation as completed
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_mark_completed(
|
pub async fn sync_mark_completed(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -147,6 +151,7 @@ pub async fn sync_mark_completed(
|
|||||||
|
|
||||||
/// Mark a sync operation as failed with error message
|
/// Mark a sync operation as failed with error message
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_mark_failed(
|
pub async fn sync_mark_failed(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
id: i64,
|
id: i64,
|
||||||
@@ -173,6 +178,7 @@ pub async fn sync_mark_failed(
|
|||||||
|
|
||||||
/// Get count of pending sync operations for a user
|
/// Get count of pending sync operations for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_get_pending_count(
|
pub async fn sync_get_pending_count(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -195,6 +201,7 @@ pub async fn sync_get_pending_count(
|
|||||||
|
|
||||||
/// Delete completed sync operations older than specified days
|
/// Delete completed sync operations older than specified days
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_cleanup_completed(
|
pub async fn sync_cleanup_completed(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
days_old: i32,
|
days_old: i32,
|
||||||
@@ -217,6 +224,7 @@ pub async fn sync_cleanup_completed(
|
|||||||
|
|
||||||
/// Delete all sync operations for a user (used during logout)
|
/// Delete all sync operations for a user (used during logout)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn sync_clear_user(
|
pub async fn sync_clear_user(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -234,3 +242,138 @@ pub async fn sync_clear_user(
|
|||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_queue_item_serialization() {
|
||||||
|
let item = SyncQueueItem {
|
||||||
|
id: 1,
|
||||||
|
user_id: "user-123".to_string(),
|
||||||
|
operation: "favorite".to_string(),
|
||||||
|
item_id: Some("item-456".to_string()),
|
||||||
|
payload: Some(r#"{"isFavorite": true}"#.to_string()),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: Some("2024-02-14T08:00:00Z".to_string()),
|
||||||
|
error_message: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should serialize successfully
|
||||||
|
let json = serde_json::to_string(&item);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("user-123"));
|
||||||
|
assert!(serialized.contains("favorite"));
|
||||||
|
assert!(serialized.contains("pending"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_queue_item_with_error() {
|
||||||
|
let item = SyncQueueItem {
|
||||||
|
id: 2,
|
||||||
|
user_id: "user-789".to_string(),
|
||||||
|
operation: "update_progress".to_string(),
|
||||||
|
item_id: Some("item-999".to_string()),
|
||||||
|
payload: None,
|
||||||
|
status: "failed".to_string(),
|
||||||
|
retry_count: 3,
|
||||||
|
created_at: Some("2024-02-14T07:00:00Z".to_string()),
|
||||||
|
error_message: Some("Connection timeout".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
assert!(json.contains("failed"));
|
||||||
|
assert!(json.contains("Connection timeout"));
|
||||||
|
assert!(json.contains("3")); // retry_count
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_queue_item_without_optional_fields() {
|
||||||
|
let item = SyncQueueItem {
|
||||||
|
id: 3,
|
||||||
|
user_id: "user-000".to_string(),
|
||||||
|
operation: "clear_progress".to_string(),
|
||||||
|
item_id: None,
|
||||||
|
payload: None,
|
||||||
|
status: "completed".to_string(),
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: None,
|
||||||
|
error_message: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
assert!(json.contains("completed"));
|
||||||
|
assert!(json.contains("null") || json.contains("\"itemId\":null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_status_values() {
|
||||||
|
// Verify all expected status values
|
||||||
|
let valid_statuses = vec!["pending", "processing", "completed", "failed"];
|
||||||
|
|
||||||
|
for status in valid_statuses {
|
||||||
|
let item = SyncQueueItem {
|
||||||
|
id: 1,
|
||||||
|
user_id: "test".to_string(),
|
||||||
|
operation: "test".to_string(),
|
||||||
|
item_id: None,
|
||||||
|
payload: None,
|
||||||
|
status: status.to_string(),
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: None,
|
||||||
|
error_message: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item).unwrap();
|
||||||
|
assert!(json.contains(status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_query_param_generation() {
|
||||||
|
// Test QueryParam generation for sync operations
|
||||||
|
let user_id = "user-123".to_string();
|
||||||
|
let operation = "favorite".to_string();
|
||||||
|
|
||||||
|
let params: Vec<QueryParam> = vec![
|
||||||
|
QueryParam::String(user_id.clone()),
|
||||||
|
QueryParam::String(operation.clone()),
|
||||||
|
QueryParam::Null,
|
||||||
|
QueryParam::Null,
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(params.len(), 4);
|
||||||
|
assert!(matches!(params[0], QueryParam::String(_)));
|
||||||
|
assert!(matches!(params[1], QueryParam::String(_)));
|
||||||
|
assert!(matches!(params[2], QueryParam::Null));
|
||||||
|
assert!(matches!(params[3], QueryParam::Null));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retry_count_increment() {
|
||||||
|
// Verify retry count management
|
||||||
|
let mut item = SyncQueueItem {
|
||||||
|
id: 1,
|
||||||
|
user_id: "user-123".to_string(),
|
||||||
|
operation: "favorite".to_string(),
|
||||||
|
item_id: None,
|
||||||
|
payload: None,
|
||||||
|
status: "pending".to_string(),
|
||||||
|
retry_count: 0,
|
||||||
|
created_at: None,
|
||||||
|
error_message: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate retries
|
||||||
|
for i in 1..=5 {
|
||||||
|
item.retry_count = i;
|
||||||
|
item.status = if i < 3 { "pending" } else { "failed" }.to_string();
|
||||||
|
|
||||||
|
assert!(item.retry_count == i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+358
-232
@@ -1,18 +1,26 @@
|
|||||||
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
|
||||||
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
|
// 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
|
||||||
|
|
||||||
|
// 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(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ConnectivityStatus {
|
pub struct ConnectivityStatus {
|
||||||
/// Whether the Jellyfin server is reachable
|
/// Whether the Jellyfin server is reachable
|
||||||
@@ -39,216 +47,122 @@ impl Default for ConnectivityStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connectivity change event emitted to frontend
|
/// Connectivity change event emitted to frontend
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ConnectivityChangeEvent {
|
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);
|
#[allow(dead_code)] // public API; currently only exercised by cross-module tests
|
||||||
|
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!(
|
||||||
|
"[ConnectivityMonitor] Network failures sustained for {:?}; declaring offline",
|
||||||
|
OFFLINE_CONFIRM_WINDOW
|
||||||
|
);
|
||||||
|
self.set_reachable(false, error).await;
|
||||||
|
} else {
|
||||||
|
log::debug!(
|
||||||
|
"[ConnectivityMonitor] Network failure within debounce window; not yet offline"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt to ping the server
|
/// Apply a deliberate reachability probe result (offline recovery probe or a
|
||||||
let is_reachable = self.http_client.ping(&ping_url).await;
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
log::debug!(
|
/// Core transition: update status and emit events only on an actual change.
|
||||||
"[ConnectivityMonitor] Ping result: {} (was: {})",
|
async fn set_reachable(&self, is_reachable: bool, error: Option<String>) {
|
||||||
if is_reachable { "SUCCESS" } else { "FAILED" },
|
let was_reachable = {
|
||||||
if was_reachable { "reachable" } else { "unreachable" }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
{
|
|
||||||
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 {
|
if is_reachable != was_reachable {
|
||||||
self.emit_connectivity_change(is_reachable).await;
|
self.emit_connectivity_change(is_reachable).await;
|
||||||
}
|
if is_reachable {
|
||||||
|
self.emit_server_reconnected().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
|
|
||||||
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
|
|
||||||
|
|
||||||
// Check if still monitoring
|
|
||||||
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 +189,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 ConnectivityMonitorHandle {
|
impl ConnectivityMonitor {
|
||||||
async fn check_reachability(&self) -> bool {
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Store previous reachability state
|
|
||||||
let was_reachable = {
|
|
||||||
let status = self.status.read().await;
|
|
||||||
status.is_server_reachable
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Attempt to ping the server
|
let ping_url = format!("{}/System/Info/Public", url);
|
||||||
|
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn emit_server_reconnected(&self) {
|
/// Mark server as unreachable directly.
|
||||||
if let Some(app_handle) = &self.app_handle {
|
///
|
||||||
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
|
/// Used by deliberate signals (e.g. a failed login/connect) where the caller
|
||||||
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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 +377,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//!
|
//!
|
||||||
//! The fallback is less secure as the encryption key is derived from machine
|
//! The fallback is less secure as the encryption key is derived from machine
|
||||||
//! identifiers, but provides functionality on headless systems.
|
//! identifiers, but provides functionality on headless systems.
|
||||||
|
//!
|
||||||
|
//! TRACES: UR-012 | IR-014
|
||||||
|
|
||||||
use aes_gcm::{
|
use aes_gcm::{
|
||||||
aead::{Aead, KeyInit},
|
aead::{Aead, KeyInit},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Smart caching engine for predictive downloads
|
//! Smart caching engine for predictive downloads
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -9,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
/// Smart caching configuration
|
/// Smart caching configuration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CacheConfig {
|
pub struct CacheConfig {
|
||||||
/// Enable queue pre-caching
|
/// Enable queue pre-caching
|
||||||
@@ -309,7 +311,7 @@ mod tests {
|
|||||||
|
|
||||||
// Add some downloads
|
// Add some downloads
|
||||||
{
|
{
|
||||||
let conn_guard = conn_arc.lock().unwrap();
|
let conn_guard = conn_arc.lock_safe();
|
||||||
conn_guard.execute(
|
conn_guard.execute(
|
||||||
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod cache;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -45,18 +46,18 @@ impl DownloadManager {
|
|||||||
|
|
||||||
/// Check if a new download can be started based on concurrent limit
|
/// Check if a new download can be started based on concurrent limit
|
||||||
pub fn can_start_download(&self) -> bool {
|
pub fn can_start_download(&self) -> bool {
|
||||||
let active = self.active_downloads.lock().unwrap();
|
let active = self.active_downloads.lock_safe();
|
||||||
active.len() < self.max_concurrent
|
active.len() < self.max_concurrent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of currently active downloads
|
/// Get the number of currently active downloads
|
||||||
pub fn active_count(&self) -> usize {
|
pub fn active_count(&self) -> usize {
|
||||||
self.active_downloads.lock().unwrap().len()
|
self.active_downloads.lock_safe().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a download as active
|
/// Register a download as active
|
||||||
pub fn register_download(&self, download_id: i64) -> bool {
|
pub fn register_download(&self, download_id: i64) -> bool {
|
||||||
let mut active = self.active_downloads.lock().unwrap();
|
let mut active = self.active_downloads.lock_safe();
|
||||||
if active.len() >= self.max_concurrent {
|
if active.len() >= self.max_concurrent {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -65,7 +66,7 @@ impl DownloadManager {
|
|||||||
|
|
||||||
/// Unregister a download when it completes or fails
|
/// Unregister a download when it completes or fails
|
||||||
pub fn unregister_download(&self, download_id: i64) {
|
pub fn unregister_download(&self, download_id: i64) {
|
||||||
let mut active = self.active_downloads.lock().unwrap();
|
let mut active = self.active_downloads.lock_safe();
|
||||||
active.remove(&download_id);
|
active.remove(&download_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ impl DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a download
|
/// Information about a download
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadInfo {
|
pub struct DownloadInfo {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -119,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() {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ impl DownloadWorker {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
.timeout(Duration::from_secs(300)) // 5 minute timeout
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client");
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
@@ -31,14 +32,18 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file with retry logic and progress tracking
|
/// Download a file with retry logic and progress tracking
|
||||||
pub async fn download(
|
pub async fn download<F>(
|
||||||
&self,
|
&self,
|
||||||
task: &DownloadTask,
|
task: &DownloadTask,
|
||||||
) -> Result<DownloadResult, DownloadError> {
|
on_progress: F,
|
||||||
|
) -> Result<DownloadResult, DownloadError>
|
||||||
|
where
|
||||||
|
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||||
|
{
|
||||||
let mut retries = 0;
|
let mut retries = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.try_download(task).await {
|
match self.try_download(task, &on_progress).await {
|
||||||
Ok(result) => return Ok(result),
|
Ok(result) => return Ok(result),
|
||||||
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
Err(e) if retries < self.max_retries && e.is_retryable() => {
|
||||||
retries += 1;
|
retries += 1;
|
||||||
@@ -55,7 +60,10 @@ impl DownloadWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt a single download
|
/// Attempt a single download
|
||||||
async fn try_download(&self, task: &DownloadTask) -> Result<DownloadResult, DownloadError> {
|
async fn try_download<F>(&self, task: &DownloadTask, on_progress: &F) -> Result<DownloadResult, DownloadError>
|
||||||
|
where
|
||||||
|
F: Fn(u64, Option<u64>) + Send + Sync,
|
||||||
|
{
|
||||||
// Create parent directories
|
// Create parent directories
|
||||||
if let Some(parent) = task.target_path.parent() {
|
if let Some(parent) = task.target_path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
@@ -129,7 +137,7 @@ impl DownloadWorker {
|
|||||||
|| downloaded % (1024 * 1024) == 0
|
|| downloaded % (1024 * 1024) == 0
|
||||||
{
|
{
|
||||||
last_progress_emit = std::time::Instant::now();
|
last_progress_emit = std::time::Instant::now();
|
||||||
// Progress events will be emitted by the manager
|
on_progress(downloaded, _total_bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-009 | JA-001, JA-002, JA-003, JA-004, JA-007, JA-010, JA-011, JA-012, JA-017, JA-021 | IR-009, IR-010, IR-011
|
||||||
|
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -20,6 +22,7 @@ impl JellyfinClient {
|
|||||||
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
pub fn new(config: JellyfinConfig) -> Result<Self, String> {
|
||||||
let http_client = Client::builder()
|
let http_client = Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
@@ -221,20 +224,20 @@ impl JellyfinClient {
|
|||||||
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
debug!("[JellyfinClient] play_on_session called: session={}, {} items, start_index={}",
|
||||||
session_id, item_ids.len(), start_index);
|
session_id, item_ids.len(), start_index);
|
||||||
|
|
||||||
// Build URL with query parameters (Jellyfin expects query params, not JSON body!)
|
// Build URL with query parameters (Jellyfin expects PascalCase query params)
|
||||||
let mut url = format!(
|
let mut url = format!(
|
||||||
"{}/Sessions/{}/Playing?playCommand=PlayNow&startIndex={}",
|
"{}/Sessions/{}/Playing?PlayCommand=PlayNow&StartIndex={}",
|
||||||
self.config.server_url, session_id, start_index
|
self.config.server_url, session_id, start_index
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add item IDs as repeated query parameters
|
// Add item IDs as repeated query parameters
|
||||||
for item_id in &item_ids {
|
for item_id in &item_ids {
|
||||||
url.push_str(&format!("&itemIds={}", item_id));
|
url.push_str(&format!("&ItemIds={}", item_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add start position if provided
|
// Add start position if provided
|
||||||
if let Some(ticks) = start_position_ticks {
|
if let Some(ticks) = start_position_ticks {
|
||||||
url.push_str(&format!("&startPositionTicks={}", ticks));
|
url.push_str(&format!("&StartPositionTicks={}", ticks));
|
||||||
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
log::info!("[JellyfinClient] Starting at position: {} ticks", ticks);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,22 +284,61 @@ impl JellyfinClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seek on a remote session
|
/// Seek on a remote session
|
||||||
|
///
|
||||||
|
/// Jellyfin's `/Sessions/{id}/Playing/Seek` endpoint takes the target as the
|
||||||
|
/// `SeekPositionTicks` *query parameter*, not a JSON body. Sending it in the
|
||||||
|
/// body (as we used to) is silently ignored and the remote never seeks.
|
||||||
pub async fn session_seek(
|
pub async fn session_seek(
|
||||||
&self,
|
&self,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
position_ticks: i64,
|
position_ticks: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
#[derive(serde::Serialize)]
|
let url = format!(
|
||||||
#[serde(rename_all = "PascalCase")]
|
"{}/Sessions/{}/Playing/Seek?SeekPositionTicks={}",
|
||||||
struct SeekRequest {
|
self.config.server_url, session_id, position_ticks
|
||||||
seek_position_ticks: i64,
|
);
|
||||||
|
|
||||||
|
let response = self.http_client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Emby-Authorization", self.get_auth_header())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Network request failed: {}", e))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Err(format!("Jellyfin API error {}: {}", status.as_u16(), error_text));
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = SeekRequest {
|
log::info!("[JellyfinClient] Seek to {} ticks on session {}", position_ticks, session_id);
|
||||||
seek_position_ticks: position_ticks,
|
Ok(())
|
||||||
};
|
}
|
||||||
|
|
||||||
self.post(&format!("/Sessions/{}/Playing/Seek", session_id), &request).await
|
/// Send a full GeneralCommand to a remote session.
|
||||||
|
/// Uses POST /Sessions/{id}/Command with a body containing Name and Arguments.
|
||||||
|
/// This is required for commands that need arguments (e.g. SetVolume).
|
||||||
|
async fn send_general_command(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
command_name: &str,
|
||||||
|
arguments: Option<serde_json::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"Name": command_name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(args) = arguments {
|
||||||
|
payload["Arguments"] = args;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[JellyfinClient] Sending GeneralCommand '{}' to session {} with payload: {}",
|
||||||
|
command_name, session_id, serde_json::to_string(&payload).unwrap_or_default());
|
||||||
|
|
||||||
|
self.post(
|
||||||
|
&format!("/Sessions/{}/Command", session_id),
|
||||||
|
&payload
|
||||||
|
).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set volume on a remote session
|
/// Set volume on a remote session
|
||||||
@@ -305,18 +347,10 @@ impl JellyfinClient {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
volume: i32,
|
volume: i32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let payload = serde_json::json!({
|
self.send_general_command(
|
||||||
"Arguments": {
|
&session_id,
|
||||||
"Volume": volume.to_string()
|
"SetVolume",
|
||||||
}
|
Some(serde_json::json!({ "Volume": volume.to_string() })),
|
||||||
});
|
|
||||||
|
|
||||||
log::info!("[JellyfinClient] Setting volume on session {} to {} with payload: {}",
|
|
||||||
session_id, volume, serde_json::to_string(&payload).unwrap_or_default());
|
|
||||||
|
|
||||||
self.post(
|
|
||||||
&format!("/Sessions/{}/Command/SetVolume", session_id),
|
|
||||||
&payload
|
|
||||||
).await
|
).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,9 +361,10 @@ impl JellyfinClient {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
log::info!("[JellyfinClient] Toggling mute on session {}", session_id);
|
||||||
|
|
||||||
self.post(
|
self.send_general_command(
|
||||||
&format!("/Sessions/{}/Command/ToggleMute", session_id),
|
&session_id,
|
||||||
&serde_json::json!({})
|
"ToggleMute",
|
||||||
|
None,
|
||||||
).await
|
).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,72 +394,103 @@ fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Session information from Jellyfin
|
/// Session information from Jellyfin
|
||||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "Id")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "UserId")]
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "UserName")]
|
||||||
pub user_name: Option<String>,
|
pub user_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "Client")]
|
||||||
pub client: Option<String>,
|
pub client: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "DeviceName")]
|
||||||
pub device_name: Option<String>,
|
pub device_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "DeviceId")]
|
||||||
pub device_id: Option<String>,
|
pub device_id: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "ApplicationVersion")]
|
||||||
pub application_version: Option<String>,
|
pub application_version: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "IsActive")]
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "SupportsMediaControl")]
|
||||||
pub supports_media_control: Option<bool>,
|
pub supports_media_control: Option<bool>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
|
#[serde(alias = "SupportsRemoteControl")]
|
||||||
pub supports_remote_control: bool,
|
pub supports_remote_control: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "NowPlayingItem")]
|
||||||
pub now_playing_item: Option<NowPlayingItem>,
|
pub now_playing_item: Option<NowPlayingItem>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "PlayState")]
|
||||||
pub play_state: Option<PlayState>,
|
pub play_state: Option<PlayState>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "PlayableMediaTypes")]
|
||||||
pub playable_media_types: Option<Vec<String>>,
|
pub playable_media_types: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "SupportedCommands")]
|
||||||
pub supported_commands: Option<Vec<String>>,
|
pub supported_commands: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct NowPlayingItem {
|
pub struct NowPlayingItem {
|
||||||
|
#[serde(alias = "Id")]
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
|
#[serde(alias = "Name")]
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
#[serde(alias = "RunTimeTicks")]
|
||||||
pub run_time_ticks: Option<i64>,
|
pub run_time_ticks: Option<i64>,
|
||||||
|
#[serde(alias = "Album")]
|
||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
|
#[serde(alias = "AlbumId")]
|
||||||
pub album_id: Option<String>,
|
pub album_id: Option<String>,
|
||||||
|
#[serde(alias = "AlbumArtist")]
|
||||||
pub album_artist: Option<String>,
|
pub album_artist: Option<String>,
|
||||||
|
#[serde(alias = "Artists")]
|
||||||
pub artists: Option<Vec<String>>,
|
pub artists: Option<Vec<String>>,
|
||||||
|
#[serde(alias = "ImageTags")]
|
||||||
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
pub image_tags: Option<std::collections::HashMap<String, String>>,
|
||||||
|
#[serde(alias = "PrimaryImageTag")]
|
||||||
pub primary_image_tag: Option<String>,
|
pub primary_image_tag: Option<String>,
|
||||||
|
#[serde(alias = "AlbumPrimaryImageTag")]
|
||||||
pub album_primary_image_tag: Option<String>,
|
pub album_primary_image_tag: Option<String>,
|
||||||
#[serde(rename = "Type")]
|
#[serde(rename = "Type")]
|
||||||
pub item_type: Option<String>,
|
pub item_type: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, Deserialize, serde::Serialize)]
|
||||||
#[serde(rename_all(deserialize = "PascalCase", serialize = "camelCase"))]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayState {
|
pub struct PlayState {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "PositionTicks")]
|
||||||
pub position_ticks: Option<i64>,
|
pub position_ticks: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "CanSeek")]
|
||||||
pub can_seek: Option<bool>,
|
pub can_seek: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "IsPaused")]
|
||||||
pub is_paused: Option<bool>,
|
pub is_paused: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "IsMuted")]
|
||||||
pub is_muted: Option<bool>,
|
pub is_muted: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "VolumeLevel")]
|
||||||
pub volume_level: Option<i32>,
|
pub volume_level: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "RepeatMode")]
|
||||||
pub repeat_mode: Option<String>,
|
pub repeat_mode: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(alias = "ShuffleMode")]
|
||||||
pub shuffle_mode: Option<String>,
|
pub shuffle_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::time::Duration;
|
|||||||
const APP_NAME: &str = "JellyTau";
|
const APP_NAME: &str = "JellyTau";
|
||||||
const APP_VERSION: &str = "0.1.0";
|
const APP_VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
// Default timeout for requests (10 seconds)
|
// Default timeout for requests (30 seconds - large library queries can be slow)
|
||||||
const DEFAULT_TIMEOUT_MS: u64 = 10000;
|
const DEFAULT_TIMEOUT_MS: u64 = 30000;
|
||||||
|
|
||||||
// Retry configuration - matches TypeScript exactly
|
// Retry configuration - matches TypeScript exactly
|
||||||
const DEFAULT_MAX_RETRIES: u32 = 3;
|
const DEFAULT_MAX_RETRIES: u32 = 3;
|
||||||
@@ -49,6 +49,7 @@ impl HttpClient {
|
|||||||
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
pub fn new(config: HttpConfig) -> Result<Self, String> {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(config.timeout)
|
.timeout(config.timeout)
|
||||||
|
.https_only(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,237 @@ pub struct PlaybackProgressRequest {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub play_session_id: Option<String>,
|
pub play_session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jellyfin_config_creation() {
|
||||||
|
let config = JellyfinConfig {
|
||||||
|
server_url: "https://jellyfin.example.com".to_string(),
|
||||||
|
access_token: "token-123".to_string(),
|
||||||
|
device_id: "device-456".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(config.server_url, "https://jellyfin.example.com");
|
||||||
|
assert_eq!(config.access_token, "token-123");
|
||||||
|
assert_eq!(config.device_id, "device-456");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jellyfin_config_clone() {
|
||||||
|
let config = JellyfinConfig {
|
||||||
|
server_url: "https://server.local".to_string(),
|
||||||
|
access_token: "token-abc".to_string(),
|
||||||
|
device_id: "device-xyz".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = config.clone();
|
||||||
|
assert_eq!(config.server_url, cloned.server_url);
|
||||||
|
assert_eq!(config.access_token, cloned.access_token);
|
||||||
|
assert_eq!(config.device_id, cloned.device_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_start_request_serialization() {
|
||||||
|
let request = PlaybackStartRequest {
|
||||||
|
item_id: "item-123".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
play_session_id: Some("session-456".to_string()),
|
||||||
|
play_command: "PlayNow".to_string(),
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("item-123"));
|
||||||
|
assert!(serialized.contains("PlayNow"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_start_request_without_session() {
|
||||||
|
let request = PlaybackStartRequest {
|
||||||
|
item_id: "item-789".to_string(),
|
||||||
|
position_ticks: 1_000_000,
|
||||||
|
play_session_id: None,
|
||||||
|
play_command: "Resume".to_string(),
|
||||||
|
is_paused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("true"));
|
||||||
|
assert!(!json.contains("playSessionId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_start_request_pascal_case() {
|
||||||
|
let request = PlaybackStartRequest {
|
||||||
|
item_id: "item-1".to_string(),
|
||||||
|
position_ticks: 100,
|
||||||
|
play_session_id: Some("session-1".to_string()),
|
||||||
|
play_command: "Play".to_string(),
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
// Verify PascalCase serialization
|
||||||
|
assert!(json.contains("ItemId"));
|
||||||
|
assert!(json.contains("PositionTicks"));
|
||||||
|
assert!(json.contains("PlaySessionId"));
|
||||||
|
assert!(json.contains("PlayCommand"));
|
||||||
|
assert!(json.contains("IsPaused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_stopped_request_serialization() {
|
||||||
|
let request = PlaybackStoppedRequest {
|
||||||
|
item_id: "item-555".to_string(),
|
||||||
|
position_ticks: 5_000_000,
|
||||||
|
play_session_id: Some("session-789".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("item-555"));
|
||||||
|
assert!(serialized.contains("5000000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_stopped_request_without_session() {
|
||||||
|
let request = PlaybackStoppedRequest {
|
||||||
|
item_id: "item-000".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
play_session_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("item-000"));
|
||||||
|
assert!(!json.contains("playSessionId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_stopped_request_pascal_case() {
|
||||||
|
let request = PlaybackStoppedRequest {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 500,
|
||||||
|
play_session_id: Some("s1".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("ItemId"));
|
||||||
|
assert!(json.contains("PositionTicks"));
|
||||||
|
assert!(json.contains("PlaySessionId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_progress_request_serialization() {
|
||||||
|
let request = PlaybackProgressRequest {
|
||||||
|
item_id: "item-999".to_string(),
|
||||||
|
position_ticks: 150_000_000,
|
||||||
|
is_paused: false,
|
||||||
|
play_session_id: Some("session-111".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("item-999"));
|
||||||
|
assert!(serialized.contains("150000000"));
|
||||||
|
assert!(serialized.contains("false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_progress_request_paused() {
|
||||||
|
let request = PlaybackProgressRequest {
|
||||||
|
item_id: "item-paused".to_string(),
|
||||||
|
position_ticks: 75_000_000,
|
||||||
|
is_paused: true,
|
||||||
|
play_session_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("true"));
|
||||||
|
assert!(json.contains("item-paused"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_progress_request_pascal_case() {
|
||||||
|
let request = PlaybackProgressRequest {
|
||||||
|
item_id: "i2".to_string(),
|
||||||
|
position_ticks: 200,
|
||||||
|
is_paused: true,
|
||||||
|
play_session_id: Some("s2".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("ItemId"));
|
||||||
|
assert!(json.contains("PositionTicks"));
|
||||||
|
assert!(json.contains("IsPaused"));
|
||||||
|
assert!(json.contains("PlaySessionId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_start_request_various_position_values() {
|
||||||
|
let positions = vec![0, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000];
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
|
let request = PlaybackStartRequest {
|
||||||
|
item_id: "item-test".to_string(),
|
||||||
|
position_ticks: pos,
|
||||||
|
play_session_id: None,
|
||||||
|
play_command: "Play".to_string(),
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(json.contains("item-test"));
|
||||||
|
assert!(json.contains(&pos.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jellyfin_config_debug() {
|
||||||
|
let config = JellyfinConfig {
|
||||||
|
server_url: "https://debug.example.com".to_string(),
|
||||||
|
access_token: "token-debug".to_string(),
|
||||||
|
device_id: "device-debug".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", config);
|
||||||
|
assert!(debug_str.contains("JellyfinConfig"));
|
||||||
|
assert!(debug_str.contains("https://debug.example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_requests_creation_patterns() {
|
||||||
|
// Test various creation patterns
|
||||||
|
let start = PlaybackStartRequest {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
play_session_id: None,
|
||||||
|
play_command: "PlayNow".to_string(),
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stopped = PlaybackStoppedRequest {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 1_000_000,
|
||||||
|
play_session_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let progress = PlaybackProgressRequest {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 500_000,
|
||||||
|
is_paused: false,
|
||||||
|
play_session_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// All should serialize successfully
|
||||||
|
assert!(serde_json::to_string(&start).is_ok());
|
||||||
|
assert!(serde_json::to_string(&stopped).is_ok());
|
||||||
|
assert!(serde_json::to_string(&progress).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+322
-229
@@ -12,11 +12,12 @@ mod session_poller;
|
|||||||
pub mod settings;
|
pub mod settings;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tauri::Manager;
|
use tauri::{Emitter, Manager};
|
||||||
|
use tauri_specta::Builder;
|
||||||
use log::{error, info};
|
use log::{error, info};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use log::warn;
|
use log::warn;
|
||||||
@@ -27,7 +28,7 @@ use commands::{
|
|||||||
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads,
|
||||||
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations,
|
||||||
get_album_affinity_status,
|
get_album_affinity_status,
|
||||||
mark_download_completed, mark_download_failed, start_download,
|
mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads,
|
||||||
pin_item, unpin_item, is_item_pinned,
|
pin_item, unpin_item, is_item_pinned,
|
||||||
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
offline_get_items, offline_is_available, offline_search, pause_download, resume_download,
|
||||||
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status,
|
||||||
@@ -95,11 +96,16 @@ use commands::{
|
|||||||
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
repository_create, repository_destroy, repository_get_libraries, repository_get_items,
|
||||||
repository_get_item, repository_get_latest_items, repository_get_resume_items,
|
repository_get_item, repository_get_latest_items, repository_get_resume_items,
|
||||||
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies,
|
||||||
|
repository_get_rediscover_albums,
|
||||||
repository_get_genres, repository_search, repository_get_playback_info,
|
repository_get_genres, repository_search, repository_get_playback_info,
|
||||||
repository_get_video_stream_url, repository_get_audio_stream_url,
|
repository_get_video_stream_url, repository_get_audio_stream_url,
|
||||||
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_create, playlist_delete, playlist_rename, playlist_get_items,
|
||||||
|
playlist_add_items, playlist_remove_items, playlist_move_item,
|
||||||
// Conversion commands
|
// Conversion commands
|
||||||
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds,
|
||||||
calc_progress, convert_percent_to_volume,
|
calc_progress, convert_percent_to_volume,
|
||||||
@@ -117,8 +123,9 @@ use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
|||||||
use download::DownloadManager;
|
use download::DownloadManager;
|
||||||
use jellyfin::{HttpClient, HttpConfig};
|
use jellyfin::{HttpClient, HttpConfig};
|
||||||
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
|
||||||
// NullBackend fallback for platforms without native backends (not Linux or Android)
|
// NullBackend is used both for platforms without a native backend AND as a graceful
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
|
||||||
|
// still launch (browse library, manage downloads, see an error) instead of crashing.
|
||||||
use player::NullBackend;
|
use player::NullBackend;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -222,13 +229,39 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Payload emitted to the frontend when a native player backend fails to
|
||||||
|
/// initialize and the app falls back to a no-op backend.
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct BackendInitError {
|
||||||
|
platform: &'static str,
|
||||||
|
backend: &'static str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log a backend-initialization failure and notify the frontend, so the UI can
|
||||||
|
/// surface "playback unavailable" instead of the app hard-crashing.
|
||||||
|
fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
|
||||||
|
error!(
|
||||||
|
"[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
|
||||||
|
backend, message
|
||||||
|
);
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"backend-init-failed",
|
||||||
|
BackendInitError {
|
||||||
|
platform: std::env::consts::OS,
|
||||||
|
backend,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the appropriate player backend for the current platform.
|
/// Create the appropriate player backend for the current platform.
|
||||||
fn create_player_backend(
|
fn create_player_backend(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||||
position_throttler: Arc<playback_reporting::EventThrottler>,
|
position_throttler: Arc<playback_reporting::EventThrottler>,
|
||||||
) -> Box<dyn PlayerBackend> {
|
) -> Box<dyn PlayerBackend> {
|
||||||
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
@@ -252,17 +285,21 @@ fn create_player_backend(
|
|||||||
return Box::new(backend);
|
return Box::new(backend);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
|
// Degrade gracefully instead of crashing the app.
|
||||||
|
emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
|
||||||
|
return Box::new(NullBackend::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
|
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e));
|
||||||
|
return Box::new(NullBackend::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
|
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e));
|
||||||
|
return Box::new(NullBackend::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +332,11 @@ fn create_player_backend(
|
|||||||
error!("\nAudio playback will NOT work until this is fixed.");
|
error!("\nAudio playback will NOT work until this is fixed.");
|
||||||
error!("========================================\n");
|
error!("========================================\n");
|
||||||
|
|
||||||
panic!("Cannot start application: MPV backend initialization failed. See error message above.");
|
// Degrade gracefully: launch with a no-op backend so the user can
|
||||||
|
// still browse the library and manage downloads, and the frontend
|
||||||
|
// can show a "playback unavailable" notice via this event.
|
||||||
|
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
||||||
|
return Box::new(NullBackend::new());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +349,249 @@ fn create_player_backend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Construct the tauri-specta command builder. Shared by `run()` and the
|
||||||
|
/// bindings-export test so the TypeScript bindings always match the handler.
|
||||||
|
fn specta_builder() -> Builder<tauri::Wry> {
|
||||||
|
Builder::<tauri::Wry>::new()
|
||||||
|
// Throw on error so generated `commands.*` return Promise<T> and throw,
|
||||||
|
// matching the existing frontend's invoke() try/catch convention.
|
||||||
|
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
|
||||||
|
.events(tauri_specta::collect_events![
|
||||||
|
crate::player::events::PlayerStatusEvent
|
||||||
|
])
|
||||||
|
.commands(tauri_specta::collect_commands![
|
||||||
|
// Player commands
|
||||||
|
player_play_item,
|
||||||
|
player_play_queue,
|
||||||
|
player_play_album_track,
|
||||||
|
player_play_tracks,
|
||||||
|
player_play,
|
||||||
|
player_pause,
|
||||||
|
player_toggle,
|
||||||
|
player_stop,
|
||||||
|
player_next,
|
||||||
|
player_previous,
|
||||||
|
player_seek,
|
||||||
|
player_seek_video,
|
||||||
|
player_set_volume,
|
||||||
|
player_toggle_mute,
|
||||||
|
player_set_audio_track,
|
||||||
|
player_switch_audio_track,
|
||||||
|
player_set_subtitle_track,
|
||||||
|
player_toggle_shuffle,
|
||||||
|
player_cycle_repeat,
|
||||||
|
player_get_status,
|
||||||
|
player_get_queue,
|
||||||
|
player_add_to_queue,
|
||||||
|
player_add_track_by_id,
|
||||||
|
player_add_tracks_by_ids,
|
||||||
|
player_remove_from_queue,
|
||||||
|
player_move_in_queue,
|
||||||
|
player_skip_to,
|
||||||
|
player_set_audio_settings,
|
||||||
|
player_get_audio_settings,
|
||||||
|
player_set_video_settings,
|
||||||
|
player_get_video_settings,
|
||||||
|
// Sleep timer and autoplay commands
|
||||||
|
player_set_sleep_timer,
|
||||||
|
player_cancel_sleep_timer,
|
||||||
|
player_get_sleep_timer,
|
||||||
|
player_get_autoplay_settings,
|
||||||
|
player_set_autoplay_settings,
|
||||||
|
player_cancel_autoplay_countdown,
|
||||||
|
player_play_next_episode,
|
||||||
|
player_on_playback_ended,
|
||||||
|
// Preload commands
|
||||||
|
player_preload_upcoming,
|
||||||
|
player_set_cache_config,
|
||||||
|
player_get_cache_config,
|
||||||
|
// Jellyfin reporting commands
|
||||||
|
player_configure_jellyfin,
|
||||||
|
player_disable_jellyfin,
|
||||||
|
// Session management commands
|
||||||
|
player_get_session,
|
||||||
|
player_dismiss_session,
|
||||||
|
// Remote session control commands
|
||||||
|
remote_play_on_session,
|
||||||
|
remote_send_command,
|
||||||
|
remote_session_seek,
|
||||||
|
remote_session_set_volume,
|
||||||
|
remote_session_toggle_mute,
|
||||||
|
// Session polling commands
|
||||||
|
sessions_set_polling_hint,
|
||||||
|
sessions_poll_now,
|
||||||
|
// Playback mode commands
|
||||||
|
playback_mode_get_current,
|
||||||
|
playback_mode_set,
|
||||||
|
playback_mode_is_transferring,
|
||||||
|
playback_mode_transfer_to_remote,
|
||||||
|
playback_mode_get_remote_status,
|
||||||
|
playback_mode_transfer_to_local,
|
||||||
|
// Playback reporting commands
|
||||||
|
playback_reporter_init,
|
||||||
|
playback_reporter_destroy,
|
||||||
|
playback_report_start,
|
||||||
|
playback_report_progress,
|
||||||
|
playback_report_stopped,
|
||||||
|
playback_mark_played,
|
||||||
|
// Auth commands
|
||||||
|
auth_initialize,
|
||||||
|
auth_connect_to_server,
|
||||||
|
auth_login,
|
||||||
|
auth_verify_session,
|
||||||
|
auth_logout,
|
||||||
|
auth_get_session,
|
||||||
|
auth_set_session,
|
||||||
|
auth_start_verification,
|
||||||
|
auth_stop_verification,
|
||||||
|
auth_reauthenticate,
|
||||||
|
// Device commands
|
||||||
|
device_get_id,
|
||||||
|
device_set_id,
|
||||||
|
// Connectivity commands
|
||||||
|
connectivity_check_server,
|
||||||
|
connectivity_set_server_url,
|
||||||
|
connectivity_get_status,
|
||||||
|
connectivity_start_monitoring,
|
||||||
|
connectivity_stop_monitoring,
|
||||||
|
connectivity_mark_reachable,
|
||||||
|
connectivity_mark_unreachable,
|
||||||
|
// Storage commands
|
||||||
|
storage_init,
|
||||||
|
storage_get_path,
|
||||||
|
storage_get_size,
|
||||||
|
storage_get_security_status,
|
||||||
|
storage_save_server,
|
||||||
|
storage_get_servers,
|
||||||
|
storage_delete_server,
|
||||||
|
storage_save_user,
|
||||||
|
storage_get_users,
|
||||||
|
storage_set_active_user,
|
||||||
|
storage_get_active_user,
|
||||||
|
storage_get_active_session,
|
||||||
|
storage_get_access_token,
|
||||||
|
storage_delete_user,
|
||||||
|
// Playback progress commands
|
||||||
|
storage_update_playback_progress,
|
||||||
|
storage_update_playback_context,
|
||||||
|
storage_mark_played,
|
||||||
|
storage_get_playback_progress,
|
||||||
|
storage_mark_synced,
|
||||||
|
storage_toggle_favorite,
|
||||||
|
// Download commands
|
||||||
|
download_item,
|
||||||
|
download_item_and_start,
|
||||||
|
download_album,
|
||||||
|
download_video,
|
||||||
|
download_series,
|
||||||
|
download_season,
|
||||||
|
get_downloads,
|
||||||
|
pause_download,
|
||||||
|
resume_download,
|
||||||
|
cancel_download,
|
||||||
|
delete_download,
|
||||||
|
delete_all_downloads,
|
||||||
|
delete_album_downloads,
|
||||||
|
clear_stale_downloads,
|
||||||
|
get_download_storage_stats,
|
||||||
|
mark_download_completed,
|
||||||
|
mark_download_failed,
|
||||||
|
start_download,
|
||||||
|
enqueue_download,
|
||||||
|
enqueue_video_downloads,
|
||||||
|
get_download_manager_stats,
|
||||||
|
set_max_concurrent_downloads,
|
||||||
|
get_smart_cache_stats,
|
||||||
|
update_smart_cache_config,
|
||||||
|
get_smart_cache_config,
|
||||||
|
get_album_recommendations,
|
||||||
|
get_album_affinity_status,
|
||||||
|
// Pinning commands
|
||||||
|
pin_item,
|
||||||
|
unpin_item,
|
||||||
|
is_item_pinned,
|
||||||
|
// Offline commands
|
||||||
|
offline_is_available,
|
||||||
|
offline_get_items,
|
||||||
|
offline_search,
|
||||||
|
// Offline cache commands
|
||||||
|
storage_get_libraries,
|
||||||
|
storage_get_items,
|
||||||
|
storage_get_item,
|
||||||
|
storage_search_items,
|
||||||
|
storage_save_library,
|
||||||
|
storage_save_item,
|
||||||
|
storage_get_pending_sync_count,
|
||||||
|
// Sync queue commands
|
||||||
|
sync_queue_mutation,
|
||||||
|
sync_get_pending,
|
||||||
|
sync_mark_processing,
|
||||||
|
sync_mark_completed,
|
||||||
|
sync_mark_failed,
|
||||||
|
sync_get_pending_count,
|
||||||
|
sync_cleanup_completed,
|
||||||
|
sync_clear_user,
|
||||||
|
// Thumbnail cache and image commands
|
||||||
|
thumbnail_get_cached,
|
||||||
|
thumbnail_save,
|
||||||
|
thumbnail_get_stats,
|
||||||
|
thumbnail_set_limit,
|
||||||
|
thumbnail_clear_cache,
|
||||||
|
thumbnail_delete_item,
|
||||||
|
image_get_url,
|
||||||
|
// People cache commands
|
||||||
|
storage_save_person,
|
||||||
|
storage_get_person,
|
||||||
|
storage_save_item_people,
|
||||||
|
storage_get_item_people,
|
||||||
|
// Series audio preferences
|
||||||
|
storage_save_series_audio_preference,
|
||||||
|
storage_get_series_audio_preference,
|
||||||
|
// Repository commands
|
||||||
|
repository_create,
|
||||||
|
repository_destroy,
|
||||||
|
repository_get_libraries,
|
||||||
|
repository_get_items,
|
||||||
|
repository_get_item,
|
||||||
|
repository_get_latest_items,
|
||||||
|
repository_get_resume_items,
|
||||||
|
repository_get_next_up_episodes,
|
||||||
|
repository_get_recently_played_audio,
|
||||||
|
repository_get_resume_movies,
|
||||||
|
repository_get_rediscover_albums,
|
||||||
|
repository_get_genres,
|
||||||
|
repository_search,
|
||||||
|
repository_get_playback_info,
|
||||||
|
repository_get_video_stream_url,
|
||||||
|
repository_get_audio_stream_url,
|
||||||
|
repository_report_playback_start,
|
||||||
|
repository_report_playback_progress,
|
||||||
|
repository_report_playback_stopped,
|
||||||
|
repository_get_image_url,
|
||||||
|
repository_mark_favorite,
|
||||||
|
repository_unmark_favorite,
|
||||||
|
repository_get_person,
|
||||||
|
repository_get_items_by_person,
|
||||||
|
repository_get_similar_items,
|
||||||
|
repository_get_subtitle_url,
|
||||||
|
repository_get_video_download_url,
|
||||||
|
// Playlist commands
|
||||||
|
playlist_create,
|
||||||
|
playlist_delete,
|
||||||
|
playlist_rename,
|
||||||
|
playlist_get_items,
|
||||||
|
playlist_add_items,
|
||||||
|
playlist_remove_items,
|
||||||
|
playlist_move_item,
|
||||||
|
// Conversion commands
|
||||||
|
format_time_seconds,
|
||||||
|
format_time_seconds_long,
|
||||||
|
convert_ticks_to_seconds,
|
||||||
|
calc_progress,
|
||||||
|
convert_percent_to_volume,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
@@ -315,10 +599,22 @@ pub fn run() {
|
|||||||
.filter_level(log::LevelFilter::Info)
|
.filter_level(log::LevelFilter::Info)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
|
||||||
|
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
|
||||||
|
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
|
||||||
|
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
|
||||||
|
let builder = specta_builder();
|
||||||
|
let invoke_handler = builder.invoke_handler();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.setup(|app| {
|
.invoke_handler(invoke_handler)
|
||||||
|
.setup(move |app| {
|
||||||
|
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
|
||||||
|
// listened for on the frontend via the generated bindings.
|
||||||
|
builder.mount_events(app);
|
||||||
|
|
||||||
// Initialize database with proper app data directory
|
// Initialize database with proper app data directory
|
||||||
// Check for test mode environment variable first
|
// Check for test mode environment variable first
|
||||||
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||||
@@ -558,224 +854,21 @@ pub fn run() {
|
|||||||
info!("[INIT] Application setup completed successfully");
|
info!("[INIT] Application setup completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
|
||||||
// Player commands
|
|
||||||
player_play_item,
|
|
||||||
player_play_queue,
|
|
||||||
player_play_album_track,
|
|
||||||
player_play_tracks,
|
|
||||||
player_play,
|
|
||||||
player_pause,
|
|
||||||
player_toggle,
|
|
||||||
player_stop,
|
|
||||||
player_next,
|
|
||||||
player_previous,
|
|
||||||
player_seek,
|
|
||||||
player_seek_video,
|
|
||||||
player_set_volume,
|
|
||||||
player_toggle_mute,
|
|
||||||
player_set_audio_track,
|
|
||||||
player_switch_audio_track,
|
|
||||||
player_set_subtitle_track,
|
|
||||||
player_toggle_shuffle,
|
|
||||||
player_cycle_repeat,
|
|
||||||
player_get_status,
|
|
||||||
player_get_queue,
|
|
||||||
player_add_to_queue,
|
|
||||||
player_add_track_by_id,
|
|
||||||
player_add_tracks_by_ids,
|
|
||||||
player_remove_from_queue,
|
|
||||||
player_move_in_queue,
|
|
||||||
player_skip_to,
|
|
||||||
player_set_audio_settings,
|
|
||||||
player_get_audio_settings,
|
|
||||||
player_set_video_settings,
|
|
||||||
player_get_video_settings,
|
|
||||||
// Sleep timer and autoplay commands
|
|
||||||
player_set_sleep_timer,
|
|
||||||
player_cancel_sleep_timer,
|
|
||||||
player_get_sleep_timer,
|
|
||||||
player_get_autoplay_settings,
|
|
||||||
player_set_autoplay_settings,
|
|
||||||
player_cancel_autoplay_countdown,
|
|
||||||
player_play_next_episode,
|
|
||||||
player_on_playback_ended,
|
|
||||||
// Preload commands
|
|
||||||
player_preload_upcoming,
|
|
||||||
player_set_cache_config,
|
|
||||||
player_get_cache_config,
|
|
||||||
// Jellyfin reporting commands
|
|
||||||
player_configure_jellyfin,
|
|
||||||
player_disable_jellyfin,
|
|
||||||
// Session management commands
|
|
||||||
player_get_session,
|
|
||||||
player_dismiss_session,
|
|
||||||
// Remote session control commands
|
|
||||||
remote_play_on_session,
|
|
||||||
remote_send_command,
|
|
||||||
remote_session_seek,
|
|
||||||
remote_session_set_volume,
|
|
||||||
remote_session_toggle_mute,
|
|
||||||
// Session polling commands
|
|
||||||
sessions_set_polling_hint,
|
|
||||||
sessions_poll_now,
|
|
||||||
// Playback mode commands
|
|
||||||
playback_mode_get_current,
|
|
||||||
playback_mode_set,
|
|
||||||
playback_mode_is_transferring,
|
|
||||||
playback_mode_transfer_to_remote,
|
|
||||||
playback_mode_get_remote_status,
|
|
||||||
playback_mode_transfer_to_local,
|
|
||||||
// Playback reporting commands
|
|
||||||
playback_reporter_init,
|
|
||||||
playback_reporter_destroy,
|
|
||||||
playback_report_start,
|
|
||||||
playback_report_progress,
|
|
||||||
playback_report_stopped,
|
|
||||||
playback_mark_played,
|
|
||||||
// Auth commands
|
|
||||||
auth_initialize,
|
|
||||||
auth_connect_to_server,
|
|
||||||
auth_login,
|
|
||||||
auth_verify_session,
|
|
||||||
auth_logout,
|
|
||||||
auth_get_session,
|
|
||||||
auth_set_session,
|
|
||||||
auth_start_verification,
|
|
||||||
auth_stop_verification,
|
|
||||||
auth_reauthenticate,
|
|
||||||
// Device commands
|
|
||||||
device_get_id,
|
|
||||||
device_set_id,
|
|
||||||
// Connectivity commands
|
|
||||||
connectivity_check_server,
|
|
||||||
connectivity_set_server_url,
|
|
||||||
connectivity_get_status,
|
|
||||||
connectivity_start_monitoring,
|
|
||||||
connectivity_stop_monitoring,
|
|
||||||
connectivity_mark_reachable,
|
|
||||||
connectivity_mark_unreachable,
|
|
||||||
// Storage commands
|
|
||||||
storage_init,
|
|
||||||
storage_get_path,
|
|
||||||
storage_get_size,
|
|
||||||
storage_get_security_status,
|
|
||||||
storage_save_server,
|
|
||||||
storage_get_servers,
|
|
||||||
storage_delete_server,
|
|
||||||
storage_save_user,
|
|
||||||
storage_get_users,
|
|
||||||
storage_set_active_user,
|
|
||||||
storage_get_active_user,
|
|
||||||
storage_get_active_session,
|
|
||||||
storage_get_access_token,
|
|
||||||
storage_delete_user,
|
|
||||||
// Playback progress commands
|
|
||||||
storage_update_playback_progress,
|
|
||||||
storage_update_playback_context,
|
|
||||||
storage_mark_played,
|
|
||||||
storage_get_playback_progress,
|
|
||||||
storage_mark_synced,
|
|
||||||
storage_toggle_favorite,
|
|
||||||
// Download commands
|
|
||||||
download_item,
|
|
||||||
download_item_and_start,
|
|
||||||
download_album,
|
|
||||||
download_video,
|
|
||||||
download_series,
|
|
||||||
download_season,
|
|
||||||
get_downloads,
|
|
||||||
pause_download,
|
|
||||||
resume_download,
|
|
||||||
cancel_download,
|
|
||||||
delete_download,
|
|
||||||
delete_all_downloads,
|
|
||||||
delete_album_downloads,
|
|
||||||
clear_stale_downloads,
|
|
||||||
get_download_storage_stats,
|
|
||||||
mark_download_completed,
|
|
||||||
mark_download_failed,
|
|
||||||
start_download,
|
|
||||||
get_download_manager_stats,
|
|
||||||
set_max_concurrent_downloads,
|
|
||||||
get_smart_cache_stats,
|
|
||||||
update_smart_cache_config,
|
|
||||||
get_smart_cache_config,
|
|
||||||
get_album_recommendations,
|
|
||||||
get_album_affinity_status,
|
|
||||||
// Pinning commands
|
|
||||||
pin_item,
|
|
||||||
unpin_item,
|
|
||||||
is_item_pinned,
|
|
||||||
// Offline commands
|
|
||||||
offline_is_available,
|
|
||||||
offline_get_items,
|
|
||||||
offline_search,
|
|
||||||
// Offline cache commands
|
|
||||||
storage_get_libraries,
|
|
||||||
storage_get_items,
|
|
||||||
storage_get_item,
|
|
||||||
storage_search_items,
|
|
||||||
storage_save_library,
|
|
||||||
storage_save_item,
|
|
||||||
storage_get_pending_sync_count,
|
|
||||||
// Sync queue commands
|
|
||||||
sync_queue_mutation,
|
|
||||||
sync_get_pending,
|
|
||||||
sync_mark_processing,
|
|
||||||
sync_mark_completed,
|
|
||||||
sync_mark_failed,
|
|
||||||
sync_get_pending_count,
|
|
||||||
sync_cleanup_completed,
|
|
||||||
sync_clear_user,
|
|
||||||
// Thumbnail cache and image commands
|
|
||||||
thumbnail_get_cached,
|
|
||||||
thumbnail_save,
|
|
||||||
thumbnail_get_stats,
|
|
||||||
thumbnail_set_limit,
|
|
||||||
thumbnail_clear_cache,
|
|
||||||
thumbnail_delete_item,
|
|
||||||
image_get_url,
|
|
||||||
// People cache commands
|
|
||||||
storage_save_person,
|
|
||||||
storage_get_person,
|
|
||||||
storage_save_item_people,
|
|
||||||
storage_get_item_people,
|
|
||||||
// Series audio preferences
|
|
||||||
storage_save_series_audio_preference,
|
|
||||||
storage_get_series_audio_preference,
|
|
||||||
// Repository commands
|
|
||||||
repository_create,
|
|
||||||
repository_destroy,
|
|
||||||
repository_get_libraries,
|
|
||||||
repository_get_items,
|
|
||||||
repository_get_item,
|
|
||||||
repository_get_latest_items,
|
|
||||||
repository_get_resume_items,
|
|
||||||
repository_get_next_up_episodes,
|
|
||||||
repository_get_recently_played_audio,
|
|
||||||
repository_get_resume_movies,
|
|
||||||
repository_get_genres,
|
|
||||||
repository_search,
|
|
||||||
repository_get_playback_info,
|
|
||||||
repository_get_video_stream_url,
|
|
||||||
repository_get_audio_stream_url,
|
|
||||||
repository_report_playback_start,
|
|
||||||
repository_report_playback_progress,
|
|
||||||
repository_report_playback_stopped,
|
|
||||||
repository_get_image_url,
|
|
||||||
repository_mark_favorite,
|
|
||||||
repository_unmark_favorite,
|
|
||||||
repository_get_person,
|
|
||||||
repository_get_items_by_person,
|
|
||||||
repository_get_similar_items,
|
|
||||||
// Conversion commands
|
|
||||||
format_time_seconds,
|
|
||||||
format_time_seconds_long,
|
|
||||||
convert_ticks_to_seconds,
|
|
||||||
calc_progress,
|
|
||||||
convert_percent_to_volume,
|
|
||||||
])
|
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod specta_bindings {
|
||||||
|
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
|
||||||
|
#[test]
|
||||||
|
fn export_typescript_bindings() {
|
||||||
|
super::specta_builder()
|
||||||
|
.export(
|
||||||
|
specta_typescript::Typescript::default().bigint(specta_typescript::BigIntExportBehavior::Number),
|
||||||
|
"../src/lib/api/bindings.ts",
|
||||||
|
)
|
||||||
|
.expect("failed to export typescript bindings");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::utils::lock::{MutexSafe, RwLockSafe};
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
@@ -11,7 +12,7 @@ use crate::jellyfin::JellyfinClient;
|
|||||||
use crate::player::{PlayerController, QueueContext};
|
use crate::player::{PlayerController, QueueContext};
|
||||||
|
|
||||||
/// Playback mode - local device, remote session, or idle
|
/// Playback mode - local device, remote session, or idle
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum PlaybackMode {
|
pub enum PlaybackMode {
|
||||||
Local,
|
Local,
|
||||||
@@ -19,6 +20,28 @@ pub enum PlaybackMode {
|
|||||||
Idle,
|
Idle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of Jellyfin ticks per second (100ns units).
|
||||||
|
const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
||||||
|
|
||||||
|
/// Below this many seconds we treat the position as "at the start" and don't
|
||||||
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
||||||
|
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
||||||
|
|
||||||
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
||||||
|
/// hand to a remote session, or `None` if we're effectively at the start.
|
||||||
|
///
|
||||||
|
/// Pure helper so the resume-position math is unit-testable without a remote
|
||||||
|
/// session or HTTP. The *source* of `position_seconds` matters too: callers
|
||||||
|
/// must pass the live backend position (`PlayerController::position()`), not the
|
||||||
|
/// snapshot embedded in `PlayerState`, which is stale mid-track on Android.
|
||||||
|
fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
||||||
|
if position_seconds > RESUME_THRESHOLD_SECONDS {
|
||||||
|
Some((position_seconds * TICKS_PER_SECOND) as i64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages playback mode transfers between local and remote sessions
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
pub struct PlaybackModeManager {
|
pub struct PlaybackModeManager {
|
||||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
@@ -43,13 +66,13 @@ impl PlaybackModeManager {
|
|||||||
|
|
||||||
/// Get current playback mode
|
/// Get current playback mode
|
||||||
pub fn get_mode(&self) -> PlaybackMode {
|
pub fn get_mode(&self) -> PlaybackMode {
|
||||||
self.current_mode.read().unwrap().clone()
|
self.current_mode.read_safe().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set playback mode (internal use)
|
/// Set playback mode (internal use)
|
||||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
let mut current = self.current_mode.write().unwrap();
|
let mut current = self.current_mode.write_safe();
|
||||||
*current = mode;
|
*current = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +181,11 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from local device to remote Jellyfin session
|
/// Transfer playback from local device to remote Jellyfin session
|
||||||
pub async fn transfer_to_remote(&self, session_id: String) -> Result<(), String> {
|
pub async fn transfer_to_remote(
|
||||||
|
&self,
|
||||||
|
session_id: String,
|
||||||
|
position_override: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
debug!("[PlaybackMode] transfer_to_remote ENTERED");
|
debug!("[PlaybackMode] transfer_to_remote ENTERED");
|
||||||
debug!("[PlaybackMode] session_id: {}", session_id);
|
debug!("[PlaybackMode] session_id: {}", session_id);
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -172,7 +199,7 @@ impl PlaybackModeManager {
|
|||||||
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
debug!("[PlaybackMode] Flag set, calling transfer_to_remote_inner");
|
||||||
|
|
||||||
// Perform the transfer
|
// Perform the transfer
|
||||||
let result = self.transfer_to_remote_inner(&session_id).await;
|
let result = self.transfer_to_remote_inner(&session_id, position_override).await;
|
||||||
|
|
||||||
// Clear transferring flag
|
// Clear transferring flag
|
||||||
self.is_transferring.store(false, Ordering::Relaxed);
|
self.is_transferring.store(false, Ordering::Relaxed);
|
||||||
@@ -180,12 +207,24 @@ impl PlaybackModeManager {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn transfer_to_remote_inner(&self, session_id: &str) -> Result<(), String> {
|
async fn transfer_to_remote_inner(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
position_override: Option<f64>,
|
||||||
|
) -> Result<(), String> {
|
||||||
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
log::info!("[PlaybackMode] transfer_to_remote_inner ENTERED");
|
||||||
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
debug!("[PlaybackMode] transfer_to_remote_inner: session_id={}", session_id);
|
||||||
|
|
||||||
|
// If we're already controlling a remote session, that *old* session — not
|
||||||
|
// the idle local player — is the source of truth for the current track and
|
||||||
|
// position. Capture it so we can resume there and stop it afterwards.
|
||||||
|
let previous_remote_session = match self.get_mode() {
|
||||||
|
PlaybackMode::Remote { session_id: prev } if prev != session_id => Some(prev),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// Get current player state and queue context
|
// Get current player state and queue context
|
||||||
let (queue_ids, current_index, position_seconds, queue_context) = {
|
let (queue_ids, mut current_index, mut position_seconds, queue_context) = {
|
||||||
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
log::info!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
debug!("[PlaybackMode] Acquiring player controller lock...");
|
debug!("[PlaybackMode] Acquiring player controller lock...");
|
||||||
let player = self.player_controller.lock().await;
|
let player = self.player_controller.lock().await;
|
||||||
@@ -193,8 +232,7 @@ impl PlaybackModeManager {
|
|||||||
debug!("[PlaybackMode] Player controller lock acquired");
|
debug!("[PlaybackMode] Player controller lock acquired");
|
||||||
|
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock().unwrap();
|
let queue = queue_arc.lock_safe();
|
||||||
let state = player.state();
|
|
||||||
|
|
||||||
let original_index = queue.current_index().unwrap_or(0);
|
let original_index = queue.current_index().unwrap_or(0);
|
||||||
let items = queue.items();
|
let items = queue.items();
|
||||||
@@ -209,7 +247,19 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
let (ids, adjusted_index) = self.extract_jellyfin_ids(items, original_index)?;
|
||||||
let position = state.position().unwrap_or(0.0);
|
// Prefer the frontend-supplied position when available. The backend
|
||||||
|
// position is unreliable as a transfer source: on Linux, *video* plays
|
||||||
|
// in the HTML5 <video> element and the MPV backend is never loaded, so
|
||||||
|
// PlayerController::position() is always 0; only the frontend knows the
|
||||||
|
// true position. We fall back to the live backend position (correct for
|
||||||
|
// Linux audio via MPV) when the frontend doesn't pass one.
|
||||||
|
let position = match position_override {
|
||||||
|
Some(p) => {
|
||||||
|
log::info!("[PlaybackMode] Using frontend position override: {:.2}s", p);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
None => player.position(),
|
||||||
|
};
|
||||||
let context = queue.context().clone();
|
let context = queue.context().clone();
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -271,12 +321,48 @@ impl PlaybackModeManager {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate position in ticks
|
// Remote -> remote switch: take the current track and position from the
|
||||||
let start_position_ticks = if position_seconds > 0.5 {
|
// session we're leaving, since the local player is idle and reports 0.
|
||||||
Some((position_seconds * 10_000_000.0) as i64)
|
if let Some(ref prev_session_id) = previous_remote_session {
|
||||||
} else {
|
log::info!(
|
||||||
None
|
"[PlaybackMode] Remote->remote switch; reading state from previous session {}",
|
||||||
};
|
prev_session_id
|
||||||
|
);
|
||||||
|
match client.get_session(prev_session_id).await {
|
||||||
|
Ok(Some(session)) => {
|
||||||
|
// Resume at the previous session's position.
|
||||||
|
if let Some(ticks) = session.play_state.as_ref().and_then(|ps| ps.position_ticks) {
|
||||||
|
position_seconds = ticks as f64 / TICKS_PER_SECOND;
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Using previous remote position: {:.2}s",
|
||||||
|
position_seconds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Resume on whichever track the previous session reached.
|
||||||
|
if let Some(now_id) = session.now_playing_item.as_ref().and_then(|i| i.id.as_deref()) {
|
||||||
|
if let Some(idx) = queue_ids.iter().position(|id| id == now_id) {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Previous session is on track {} (queue index {})",
|
||||||
|
now_id,
|
||||||
|
idx
|
||||||
|
);
|
||||||
|
current_index = idx;
|
||||||
|
} else {
|
||||||
|
log::warn!(
|
||||||
|
"[PlaybackMode] Previous session's track {} not found in queue; keeping index {}",
|
||||||
|
now_id,
|
||||||
|
current_index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => log::warn!("[PlaybackMode] Previous remote session not found while reading state"),
|
||||||
|
Err(e) => log::warn!("[PlaybackMode] Failed to read previous remote session: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position in ticks (from the live position read above)
|
||||||
|
let start_position_ticks = start_position_ticks_from_seconds(position_seconds);
|
||||||
|
|
||||||
// Log queue context for debugging (context is tracked but we always send track IDs)
|
// Log queue context for debugging (context is tracked but we always send track IDs)
|
||||||
match &queue_context {
|
match &queue_context {
|
||||||
@@ -391,6 +477,33 @@ impl PlaybackModeManager {
|
|||||||
return Err("Remote session did not load track in time".to_string());
|
return Err("Remote session did not load track in time".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resume at the right position. We send StartPositionTicks in the play
|
||||||
|
// command above, but some Jellyfin client/server combinations ignore it
|
||||||
|
// and start from 0. Now that the track is confirmed loaded, issue an
|
||||||
|
// explicit seek as well (mirrors how the local resume path works). This
|
||||||
|
// is the reliable mechanism; StartPositionTicks is best-effort.
|
||||||
|
if let Some(ticks) = start_position_ticks {
|
||||||
|
log::info!(
|
||||||
|
"[PlaybackMode] Seeking remote session to resume position: {} ticks",
|
||||||
|
ticks
|
||||||
|
);
|
||||||
|
if let Err(e) = client.session_seek(session_id.to_string(), ticks).await {
|
||||||
|
// Non-fatal: the track is already playing, just not at the
|
||||||
|
// resume point. Log and continue rather than failing the transfer.
|
||||||
|
log::warn!("[PlaybackMode] Resume seek on remote failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote -> remote switch: stop the session we just left so we don't end
|
||||||
|
// up with two devices playing at once. Do this only after the new session
|
||||||
|
// is confirmed playing, so a failure here doesn't leave us with silence.
|
||||||
|
if let Some(prev_session_id) = previous_remote_session {
|
||||||
|
log::info!("[PlaybackMode] Stopping previous remote session {}", prev_session_id);
|
||||||
|
if let Err(e) = client.send_session_command(prev_session_id, "Stop").await {
|
||||||
|
log::warn!("[PlaybackMode] Failed to stop previous remote session: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop local playback (queue should remain intact for remote session)
|
// Stop local playback (queue should remain intact for remote session)
|
||||||
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
log::info!("[PlaybackMode] Stopping local playback - queue should NOT be cleared");
|
||||||
{
|
{
|
||||||
@@ -399,7 +512,7 @@ impl PlaybackModeManager {
|
|||||||
// Log queue state BEFORE stop
|
// Log queue state BEFORE stop
|
||||||
{
|
{
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock().unwrap();
|
let queue = queue_arc.lock_safe();
|
||||||
info!(
|
info!(
|
||||||
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
|
||||||
queue.items().len(),
|
queue.items().len(),
|
||||||
@@ -412,7 +525,7 @@ impl PlaybackModeManager {
|
|||||||
// Log queue state AFTER stop (should be unchanged)
|
// Log queue state AFTER stop (should be unchanged)
|
||||||
{
|
{
|
||||||
let queue_arc = player.queue();
|
let queue_arc = player.queue();
|
||||||
let queue = queue_arc.lock().unwrap();
|
let queue = queue_arc.lock_safe();
|
||||||
info!(
|
info!(
|
||||||
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
|
||||||
queue.items().len(),
|
queue.items().len(),
|
||||||
@@ -576,6 +689,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The resume position handed to a remote session is derived from a live
|
||||||
|
/// playback position. Guards the seconds->ticks conversion and the
|
||||||
|
/// at-the-start threshold (Bug: casting restarted the track from 0).
|
||||||
|
#[test]
|
||||||
|
fn test_start_position_ticks_from_seconds() {
|
||||||
|
// Mid-track positions convert to ticks (10M ticks per second).
|
||||||
|
assert_eq!(start_position_ticks_from_seconds(5.0), Some(50_000_000));
|
||||||
|
assert_eq!(start_position_ticks_from_seconds(123.45), Some(1_234_500_000));
|
||||||
|
|
||||||
|
// At/near the start, send no resume position so the track casts from 0.
|
||||||
|
assert_eq!(start_position_ticks_from_seconds(0.0), None);
|
||||||
|
assert_eq!(start_position_ticks_from_seconds(0.5), None);
|
||||||
|
|
||||||
|
// Just past the threshold resumes rather than restarting.
|
||||||
|
assert!(start_position_ticks_from_seconds(0.6).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
// Tests for extract_jellyfin_ids - verify all track IDs are sent to remote, not just album/playlist ID
|
||||||
mod extract_jellyfin_ids_tests {
|
mod extract_jellyfin_ids_tests {
|
||||||
use crate::player::{MediaItem, MediaSource, MediaType};
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -34,7 +35,7 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Checks if enough time has elapsed since the last report for this item
|
/// Checks if enough time has elapsed since the last report for this item
|
||||||
pub fn should_report(&self, item_id: &str) -> bool {
|
pub fn should_report(&self, item_id: &str) -> bool {
|
||||||
let last_times = self.last_report_time.lock().unwrap();
|
let last_times = self.last_report_time.lock_safe();
|
||||||
|
|
||||||
if let Some(last_time) = last_times.get(item_id) {
|
if let Some(last_time) = last_times.get(item_id) {
|
||||||
let elapsed = last_time.elapsed();
|
let elapsed = last_time.elapsed();
|
||||||
@@ -54,7 +55,7 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Marks the item as reported at the current time
|
/// Marks the item as reported at the current time
|
||||||
pub fn mark_reported(&self, item_id: &str) {
|
pub fn mark_reported(&self, item_id: &str) {
|
||||||
let mut last_times = self.last_report_time.lock().unwrap();
|
let mut last_times = self.last_report_time.lock_safe();
|
||||||
last_times.insert(item_id.to_string(), Instant::now());
|
last_times.insert(item_id.to_string(), Instant::now());
|
||||||
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
@@ -66,14 +67,14 @@ impl EventThrottler {
|
|||||||
|
|
||||||
/// Clears all tracked report times
|
/// Clears all tracked report times
|
||||||
pub fn clear(&self) {
|
pub fn clear(&self) {
|
||||||
let mut last_times = self.last_report_time.lock().unwrap();
|
let mut last_times = self.last_report_time.lock_safe();
|
||||||
last_times.clear();
|
last_times.clear();
|
||||||
log::debug!("[EventThrottler] Cleared all tracked report times");
|
log::debug!("[EventThrottler] Cleared all tracked report times");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a specific item from tracking
|
/// Removes a specific item from tracking
|
||||||
pub fn clear_item(&self, item_id: &str) {
|
pub fn clear_item(&self, item_id: &str) {
|
||||||
let mut last_times = self.last_report_time.lock().unwrap();
|
let mut last_times = self.last_report_time.lock_safe();
|
||||||
last_times.remove(item_id);
|
last_times.remove(item_id);
|
||||||
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
|
log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
||||||
//! through JNI calls to Kotlin code.
|
//! through JNI calls to Kotlin code.
|
||||||
|
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
@@ -303,7 +304,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
{
|
{
|
||||||
let mut state = self.shared_state.lock().unwrap();
|
let mut state = self.shared_state.lock_safe();
|
||||||
state.current_media = Some(media.clone());
|
state.current_media = Some(media.clone());
|
||||||
state.state = PlayerState::Loading {
|
state.state = PlayerState::Loading {
|
||||||
media: media.clone(),
|
media: media.clone(),
|
||||||
@@ -430,7 +431,7 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
|
|
||||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||||
{
|
{
|
||||||
let mut state = self.shared_state.lock().unwrap();
|
let mut state = self.shared_state.lock_safe();
|
||||||
state.state = PlayerState::Idle;
|
state.state = PlayerState::Idle;
|
||||||
state.is_loaded = false;
|
state.is_loaded = false;
|
||||||
state.current_media = None;
|
state.current_media = None;
|
||||||
@@ -479,24 +480,24 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
)
|
)
|
||||||
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
|
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
|
||||||
|
|
||||||
self.shared_state.lock().unwrap().volume = clamped;
|
self.shared_state.lock_safe().volume = clamped;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn position(&self) -> f64 {
|
fn position(&self) -> f64 {
|
||||||
self.shared_state.lock().unwrap().position
|
self.shared_state.lock_safe().position
|
||||||
}
|
}
|
||||||
|
|
||||||
fn duration(&self) -> Option<f64> {
|
fn duration(&self) -> Option<f64> {
|
||||||
self.shared_state.lock().unwrap().duration
|
self.shared_state.lock_safe().duration
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> PlayerState {
|
fn state(&self) -> PlayerState {
|
||||||
self.shared_state.lock().unwrap().state.clone()
|
self.shared_state.lock_safe().state.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn volume(&self) -> f32 {
|
fn volume(&self) -> f32 {
|
||||||
self.shared_state.lock().unwrap().volume
|
self.shared_state.lock_safe().volume
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||||
@@ -564,7 +565,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Update state and get the preserved duration to emit
|
// Update state and get the preserved duration to emit
|
||||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock_safe();
|
||||||
state.position = position;
|
state.position = position;
|
||||||
if duration > 0.0 {
|
if duration > 0.0 {
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
@@ -606,7 +607,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Update shared state
|
// Update shared state
|
||||||
if let Some(shared) = SHARED_STATE.get() {
|
if let Some(shared) = SHARED_STATE.get() {
|
||||||
let mut shared = shared.lock().unwrap();
|
let mut shared = shared.lock_safe();
|
||||||
if let Some(media) = shared.current_media.clone() {
|
if let Some(media) = shared.current_media.clone() {
|
||||||
let duration = shared.duration.unwrap_or(0.0);
|
let duration = shared.duration.unwrap_or(0.0);
|
||||||
let position = shared.position;
|
let position = shared.position;
|
||||||
@@ -651,7 +652,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock().unwrap();
|
let mut state = state.lock_safe();
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
state.is_loaded = true;
|
state.is_loaded = true;
|
||||||
}
|
}
|
||||||
@@ -677,7 +678,12 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match controller.lock().await.on_playback_ended().await {
|
// Compute the autoplay decision and release the lock before matching.
|
||||||
|
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||||
|
// arm, which re-locks the controller to call next() — leaving playback
|
||||||
|
// stopped (paused at position 0) instead of advancing.
|
||||||
|
let decision = controller.lock().await.on_playback_ended().await;
|
||||||
|
match decision {
|
||||||
Ok(AutoplayDecision::Stop) => {
|
Ok(AutoplayDecision::Stop) => {
|
||||||
log::debug!("[Autoplay] Decision: Stop playback");
|
log::debug!("[Autoplay] Decision: Stop playback");
|
||||||
// Emit PlaybackEnded event to frontend
|
// Emit PlaybackEnded event to frontend
|
||||||
@@ -692,7 +698,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
|
|
||||||
// Log queue state before advancing
|
// Log queue state before advancing
|
||||||
let queue_info = {
|
let queue_info = {
|
||||||
let queue = ctrl.queue.lock().unwrap();
|
let queue = ctrl.queue.lock_safe();
|
||||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||||
};
|
};
|
||||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||||
@@ -702,7 +708,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
log::info!("[Autoplay] Successfully advanced to next track");
|
log::info!("[Autoplay] Successfully advanced to next track");
|
||||||
// Log queue state after advancing
|
// Log queue state after advancing
|
||||||
let queue_info = {
|
let queue_info = {
|
||||||
let queue = ctrl.queue.lock().unwrap();
|
let queue = ctrl.queue.lock_safe();
|
||||||
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
|
||||||
};
|
};
|
||||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||||
@@ -811,7 +817,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
muted: jboolean,
|
muted: jboolean,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
if let Some(state) = SHARED_STATE.get() {
|
||||||
state.lock().unwrap().volume = volume;
|
state.lock_safe().volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::repository::types::MediaItem;
|
use crate::repository::types::MediaItem;
|
||||||
|
|
||||||
/// Autoplay decision result - determines what happens after playback ends
|
/// Autoplay decision result - determines what happens after playback ends
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "action", rename_all = "camelCase")]
|
#[serde(tag = "action", rename_all = "camelCase")]
|
||||||
pub enum AutoplayDecision {
|
pub enum AutoplayDecision {
|
||||||
/// Stop playback (no next item or timer expired)
|
/// Stop playback (no next item or timer expired)
|
||||||
@@ -21,13 +21,16 @@ pub enum AutoplayDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Autoplay settings (controls next episode behavior)
|
/// Autoplay settings (controls next episode behavior)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AutoplaySettings {
|
pub struct AutoplaySettings {
|
||||||
/// Whether autoplay is enabled for next episodes
|
/// Whether autoplay is enabled for next episodes
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Countdown duration in seconds before auto-playing next episode
|
/// Countdown duration in seconds before auto-playing next episode
|
||||||
pub countdown_seconds: u32,
|
pub countdown_seconds: u32,
|
||||||
|
/// Maximum number of episodes to auto-play consecutively (0 = unlimited)
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_episodes: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AutoplaySettings {
|
impl Default for AutoplaySettings {
|
||||||
@@ -35,6 +38,7 @@ impl Default for AutoplaySettings {
|
|||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 10,
|
countdown_seconds: 10,
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +60,24 @@ mod tests {
|
|||||||
let settings = AutoplaySettings::default();
|
let settings = AutoplaySettings::default();
|
||||||
assert!(settings.enabled);
|
assert!(settings.enabled);
|
||||||
assert_eq!(settings.countdown_seconds, 10);
|
assert_eq!(settings.countdown_seconds, 10);
|
||||||
|
assert_eq!(settings.max_episodes, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autoplay_settings_backward_compat() {
|
||||||
|
// Deserialize old JSON without max_episodes field
|
||||||
|
let json = r#"{"enabled":true,"countdownSeconds":15}"#;
|
||||||
|
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(settings.enabled);
|
||||||
|
assert_eq!(settings.countdown_seconds, 15);
|
||||||
|
assert_eq!(settings.max_episodes, 0); // defaults to 0 (unlimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_autoplay_settings_with_max_episodes() {
|
||||||
|
let json = r#"{"enabled":true,"countdownSeconds":10,"maxEpisodes":5}"#;
|
||||||
|
let settings: AutoplaySettings = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(settings.max_episodes, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -63,6 +85,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 2, // Too short
|
countdown_seconds: 2, // Too short
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
assert_eq!(settings.countdown_seconds, 5); // Clamped to min
|
||||||
@@ -70,6 +93,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 60, // Too long
|
countdown_seconds: 60, // Too long
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
assert_eq!(settings.countdown_seconds, 30); // Clamped to max
|
||||||
@@ -77,6 +101,7 @@ mod tests {
|
|||||||
let settings = AutoplaySettings {
|
let settings = AutoplaySettings {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
countdown_seconds: 15, // Valid
|
countdown_seconds: 15, // Valid
|
||||||
|
max_episodes: 0,
|
||||||
}
|
}
|
||||||
.with_validated_countdown();
|
.with_validated_countdown();
|
||||||
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
assert_eq!(settings.countdown_seconds, 15); // Unchanged
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
//!
|
//!
|
||||||
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::AppHandle;
|
||||||
|
use tauri_specta::Event;
|
||||||
|
|
||||||
use super::{MediaSessionType, SleepTimerMode};
|
use super::{MediaSessionType, SleepTimerMode};
|
||||||
|
|
||||||
@@ -18,7 +21,14 @@ use super::{MediaSessionType, SleepTimerMode};
|
|||||||
/// state machine transitions.
|
/// state machine transitions.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
|
||||||
|
// NOTE: fields are intentionally snake_case on the wire. specta generates the
|
||||||
|
// TypeScript bindings with snake_case field names (it does not apply serde's
|
||||||
|
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
|
||||||
|
// serde emit camelCase payloads that no longer match the generated schema —
|
||||||
|
// tauri-specta then silently drops those events (e.g. state_changed,
|
||||||
|
// queue_changed never reach the frontend, so the mini player never appears).
|
||||||
|
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum PlayerStatusEvent {
|
pub enum PlayerStatusEvent {
|
||||||
/// Playback position updated (emitted periodically during playback)
|
/// Playback position updated (emitted periodically during playback)
|
||||||
@@ -111,9 +121,6 @@ pub enum PlayerStatusEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tauri event name for player status events
|
|
||||||
pub const PLAYER_EVENT_NAME: &str = "player-event";
|
|
||||||
|
|
||||||
/// Trait for emitting player events to the frontend.
|
/// Trait for emitting player events to the frontend.
|
||||||
///
|
///
|
||||||
/// This abstraction allows backends to emit events without depending
|
/// This abstraction allows backends to emit events without depending
|
||||||
@@ -139,7 +146,9 @@ impl TauriEventEmitter {
|
|||||||
|
|
||||||
impl PlayerEventEmitter for TauriEventEmitter {
|
impl PlayerEventEmitter for TauriEventEmitter {
|
||||||
fn emit(&self, event: PlayerStatusEvent) {
|
fn emit(&self, event: PlayerStatusEvent) {
|
||||||
if let Err(e) = self.app_handle.emit(PLAYER_EVENT_NAME, &event) {
|
// Emitted via the tauri-specta Event trait so the payload shape and event
|
||||||
|
// name match the generated TypeScript bindings (events.playerStatusEvent).
|
||||||
|
if let Err(e) = Event::emit(&event, &self.app_handle) {
|
||||||
error!("Failed to emit player event: {}", e);
|
error!("Failed to emit player event: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,17 +177,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
||||||
self.events.lock().unwrap().clone()
|
self.events.lock_safe().clone()
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&self) {
|
|
||||||
self.events.lock().unwrap().clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEventEmitter for TestEventEmitter {
|
impl PlayerEventEmitter for TestEventEmitter {
|
||||||
fn emit(&self, event: PlayerStatusEvent) {
|
fn emit(&self, event: PlayerStatusEvent) {
|
||||||
self.events.lock().unwrap().push(event);
|
self.events.lock_safe().push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
/// Context for the current queue - where did the queue items come from?
|
/// Context for the current queue - where did the queue items come from?
|
||||||
/// This is used for remote playback transfer to send album/playlist context.
|
/// This is used for remote playback transfer to send album/playlist context.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum QueueContext {
|
pub enum QueueContext {
|
||||||
/// Playing from a specific album
|
/// Playing from a specific album
|
||||||
@@ -23,7 +23,7 @@ pub enum QueueContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a subtitle track
|
/// Represents a subtitle track
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct SubtitleTrack {
|
pub struct SubtitleTrack {
|
||||||
/// Stream index in the media source
|
/// Stream index in the media source
|
||||||
pub index: i32,
|
pub index: i32,
|
||||||
@@ -40,8 +40,9 @@ pub struct SubtitleTrack {
|
|||||||
/// Represents a media item that can be played
|
/// Represents a media item that can be played
|
||||||
///
|
///
|
||||||
/// TRACES: UR-003, UR-004 | DR-002
|
/// TRACES: UR-003, UR-004 | DR-002
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[specta(rename = "PlayerMediaItem")]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
/// Unique identifier
|
/// Unique identifier
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -106,7 +107,7 @@ pub struct MediaItem {
|
|||||||
pub server_id: Option<String>,
|
pub server_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum MediaType {
|
pub enum MediaType {
|
||||||
Audio,
|
Audio,
|
||||||
@@ -114,8 +115,9 @@ pub enum MediaType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
|
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
|
#[specta(rename = "PlayerMediaSource")]
|
||||||
pub enum MediaSource {
|
pub enum MediaSource {
|
||||||
/// Streaming from Jellyfin server
|
/// Streaming from Jellyfin server
|
||||||
Remote {
|
Remote {
|
||||||
@@ -156,3 +158,390 @@ impl MediaItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_album() {
|
||||||
|
let context = QueueContext::Album {
|
||||||
|
album_id: "album-123".to_string(),
|
||||||
|
album_name: "Test Album".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(context, QueueContext::Album { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_playlist() {
|
||||||
|
let context = QueueContext::Playlist {
|
||||||
|
playlist_id: "playlist-456".to_string(),
|
||||||
|
playlist_name: "Test Playlist".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(context, QueueContext::Playlist { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_custom() {
|
||||||
|
let context = QueueContext::Custom;
|
||||||
|
assert!(matches!(context, QueueContext::Custom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_serialization() {
|
||||||
|
let context = QueueContext::Album {
|
||||||
|
album_id: "alb-001".to_string(),
|
||||||
|
album_name: "Album 001".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&context);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("album"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_default() {
|
||||||
|
let context = QueueContext::default();
|
||||||
|
assert!(matches!(context, QueueContext::Custom));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_context_clone() {
|
||||||
|
let context = QueueContext::Album {
|
||||||
|
album_id: "clone-alb".to_string(),
|
||||||
|
album_name: "Clone Album".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = context.clone();
|
||||||
|
assert_eq!(context, cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtitle_track_creation() {
|
||||||
|
let track = SubtitleTrack {
|
||||||
|
index: 0,
|
||||||
|
url: "https://example.com/subs.vtt".to_string(),
|
||||||
|
language: Some("eng".to_string()),
|
||||||
|
label: Some("English".to_string()),
|
||||||
|
mime_type: "text/vtt".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(track.index, 0);
|
||||||
|
assert_eq!(track.language, Some("eng".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtitle_track_without_language() {
|
||||||
|
let track = SubtitleTrack {
|
||||||
|
index: 1,
|
||||||
|
url: "https://example.com/subs.srt".to_string(),
|
||||||
|
language: None,
|
||||||
|
label: Some("Subtitles".to_string()),
|
||||||
|
mime_type: "application/x-subrip".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(track.language.is_none());
|
||||||
|
assert!(track.label.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_subtitle_track_serialization() {
|
||||||
|
let track = SubtitleTrack {
|
||||||
|
index: 0,
|
||||||
|
url: "url.vtt".to_string(),
|
||||||
|
language: Some("eng".to_string()),
|
||||||
|
label: None,
|
||||||
|
mime_type: "text/vtt".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&track);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("0"));
|
||||||
|
assert!(serialized.contains("eng"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_type_audio() {
|
||||||
|
let media_type = MediaType::Audio;
|
||||||
|
let json = serde_json::to_string(&media_type).unwrap();
|
||||||
|
assert!(json.contains("audio"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_type_video() {
|
||||||
|
let media_type = MediaType::Video;
|
||||||
|
let json = serde_json::to_string(&media_type).unwrap();
|
||||||
|
assert!(json.contains("video"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_source_remote() {
|
||||||
|
let source = MediaSource::Remote {
|
||||||
|
stream_url: "https://server.com/video.mp4".to_string(),
|
||||||
|
jellyfin_item_id: "item-123".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(source, MediaSource::Remote { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_source_local() {
|
||||||
|
let source = MediaSource::Local {
|
||||||
|
file_path: PathBuf::from("/path/to/video.mp4"),
|
||||||
|
jellyfin_item_id: Some("item-456".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(source, MediaSource::Local { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_source_local_without_jellyfin_id() {
|
||||||
|
let source = MediaSource::Local {
|
||||||
|
file_path: PathBuf::from("/downloads/audio.mp3"),
|
||||||
|
jellyfin_item_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(source, MediaSource::Local { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_source_direct_url() {
|
||||||
|
let source = MediaSource::DirectUrl {
|
||||||
|
url: "https://external.com/stream".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(source, MediaSource::DirectUrl { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_source_serialization() {
|
||||||
|
let source = MediaSource::DirectUrl {
|
||||||
|
url: "https://example.com/stream".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&source);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("directurl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_creation_minimal() {
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "item-1".to_string(),
|
||||||
|
title: "Test Item".to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
source: MediaSource::DirectUrl {
|
||||||
|
url: "https://example.com/video".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(item.id, "item-1");
|
||||||
|
assert_eq!(item.title, "Test Item");
|
||||||
|
assert!(!item.needs_transcoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_jellyfin_id() {
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "item-2".to_string(),
|
||||||
|
title: "Test".to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "https://server/stream".to_string(),
|
||||||
|
jellyfin_item_id: "jf-id-123".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(item.jellyfin_id(), Some("jf-id-123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_jellyfin_id_local() {
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "item-3".to_string(),
|
||||||
|
title: "Local".to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
source: MediaSource::Local {
|
||||||
|
file_path: PathBuf::from("/local/video.mp4"),
|
||||||
|
jellyfin_item_id: Some("jf-local".to_string()),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(item.jellyfin_id(), Some("jf-local"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_jellyfin_id_direct_url() {
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "item-4".to_string(),
|
||||||
|
title: "Direct".to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
source: MediaSource::DirectUrl {
|
||||||
|
url: "https://external.com/media".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(item.jellyfin_id(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_with_subtitles() {
|
||||||
|
let sub = SubtitleTrack {
|
||||||
|
index: 0,
|
||||||
|
url: "subs.vtt".to_string(),
|
||||||
|
language: Some("eng".to_string()),
|
||||||
|
label: Some("English".to_string()),
|
||||||
|
mime_type: "text/vtt".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "item-subs".to_string(),
|
||||||
|
title: "With Subs".to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
source: MediaSource::DirectUrl {
|
||||||
|
url: "video.mp4".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![sub],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(item.subtitles.len(), 1);
|
||||||
|
assert_eq!(item.subtitles[0].language, Some("eng".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_media_item_serialization() {
|
||||||
|
let item = MediaItem {
|
||||||
|
id: "serial-item".to_string(),
|
||||||
|
title: "Serial Test".to_string(),
|
||||||
|
name: Some("Name".to_string()),
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: Some("Movie".to_string()),
|
||||||
|
playlist_id: None,
|
||||||
|
duration: Some(120.0),
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Video,
|
||||||
|
source: MediaSource::DirectUrl {
|
||||||
|
url: "https://example.com/movie.mp4".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: Some("h264".to_string()),
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: Some(1920),
|
||||||
|
video_height: Some(1080),
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&item);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("serial-item"));
|
||||||
|
assert!(serialized.contains("Serial Test"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+385
-98
@@ -7,6 +7,7 @@ pub mod backend;
|
|||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
|
pub mod seek;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sleep_timer;
|
pub mod sleep_timer;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
@@ -27,6 +28,7 @@ pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
|||||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
||||||
pub use queue::{QueueManager, RepeatMode};
|
pub use queue::{QueueManager, RepeatMode};
|
||||||
|
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
||||||
pub use session::{MediaSessionManager, MediaSessionType};
|
pub use session::{MediaSessionManager, MediaSessionType};
|
||||||
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
|
pub use sleep_timer::{SleepTimerMode, SleepTimerState};
|
||||||
pub use state::{EndReason, PlayerState};
|
pub use state::{EndReason, PlayerState};
|
||||||
@@ -44,7 +46,8 @@ pub use android::{
|
|||||||
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::{debug, warn};
|
use crate::utils::lock::MutexSafe;
|
||||||
|
use log::{debug, error, warn};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
@@ -86,6 +89,9 @@ pub struct PlayerController {
|
|||||||
|
|
||||||
// End reason tracking for autoplay decision making
|
// End reason tracking for autoplay decision making
|
||||||
end_reason: Arc<Mutex<Option<EndReason>>>,
|
end_reason: Arc<Mutex<Option<EndReason>>>,
|
||||||
|
|
||||||
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
|
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerController {
|
impl PlayerController {
|
||||||
@@ -107,6 +113,7 @@ impl PlayerController {
|
|||||||
playback_reporter,
|
playback_reporter,
|
||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start background timer thread for sleep timer countdown
|
// Start background timer thread for sleep timer countdown
|
||||||
@@ -117,7 +124,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Configure the Jellyfin API client for automatic playback reporting
|
/// Configure the Jellyfin API client for automatic playback reporting
|
||||||
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
|
||||||
let mut jellyfin = self.jellyfin_client.lock().unwrap();
|
let mut jellyfin = self.jellyfin_client.lock_safe();
|
||||||
*jellyfin = client;
|
*jellyfin = client;
|
||||||
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
|
||||||
}
|
}
|
||||||
@@ -153,22 +160,49 @@ impl PlayerController {
|
|||||||
/// Set the end reason for the next playback end event
|
/// Set the end reason for the next playback end event
|
||||||
fn set_end_reason(&self, reason: EndReason) {
|
fn set_end_reason(&self, reason: EndReason) {
|
||||||
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
|
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
|
||||||
*self.end_reason.lock().unwrap() = Some(reason);
|
*self.end_reason.lock_safe() = Some(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get and clear the current end reason
|
/// Get and clear the current end reason
|
||||||
fn take_end_reason(&self) -> Option<EndReason> {
|
fn take_end_reason(&self) -> Option<EndReason> {
|
||||||
self.end_reason.lock().unwrap().take()
|
self.end_reason.lock_safe().take()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Increment autoplay episode counter. Returns true if limit is reached.
|
||||||
|
fn increment_autoplay_count(&self) -> bool {
|
||||||
|
let max = self.autoplay_settings.lock_safe().max_episodes;
|
||||||
|
|
||||||
|
if max == 0 {
|
||||||
|
// Unlimited
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = self.autoplay_episode_count.lock_safe();
|
||||||
|
*count += 1;
|
||||||
|
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
|
||||||
|
|
||||||
|
*count >= max
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset autoplay episode counter (called on manual play actions)
|
||||||
|
fn reset_autoplay_count(&self) {
|
||||||
|
let mut count = self.autoplay_episode_count.lock_safe();
|
||||||
|
if *count > 0 {
|
||||||
|
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
|
||||||
|
}
|
||||||
|
*count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Load and play a single item (also sets the queue to contain only this item)
|
/// Load and play a single item (also sets the queue to contain only this item)
|
||||||
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
pub fn play_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play_item: {}", item.title);
|
debug!("[PlayerController] play_item: {}", item.title);
|
||||||
|
|
||||||
|
// Reset autoplay counter on manual play
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
// Update queue with this single item
|
// Update queue with this single item
|
||||||
{
|
{
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock_safe();
|
||||||
queue.set_queue(vec![item.clone()], 0);
|
queue.set_queue(vec![item.clone()], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +212,23 @@ impl PlayerController {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the current queue item without loading it into the playback backend.
|
||||||
|
///
|
||||||
|
/// Used on platforms where video is rendered outside the native backend
|
||||||
|
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
|
||||||
|
/// item, but MPV must not start a redundant decode for it.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||||
|
debug!("[PlayerController] set_current_item (no backend load): {}", item.title);
|
||||||
|
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
|
let mut queue = self.queue.lock_safe();
|
||||||
|
queue.set_queue(vec![item], 0);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Load and play an item without modifying the queue
|
/// Load and play an item without modifying the queue
|
||||||
/// Use this when the queue is already set up and you just want to play a specific item from it
|
/// Use this when the queue is already set up and you just want to play a specific item from it
|
||||||
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
|
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
|
||||||
@@ -186,7 +237,7 @@ impl PlayerController {
|
|||||||
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
|
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
|
||||||
self.set_end_reason(EndReason::NewTrackLoaded);
|
self.set_end_reason(EndReason::NewTrackLoaded);
|
||||||
|
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.load(item)?;
|
backend.load(item)?;
|
||||||
backend.play()?;
|
backend.play()?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
@@ -260,37 +311,86 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Set the queue and start playing from the specified index
|
/// Set the queue and start playing from the specified index
|
||||||
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
pub fn play_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play_queue: {} items, starting at index {}", items.len(), start_index);
|
self.play_queue_from(items, start_index, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the queue and start playing from the specified index, optionally
|
||||||
|
/// resuming the starting track at `start_position` (seconds).
|
||||||
|
///
|
||||||
|
/// The seek happens immediately after load so the backend never audibly
|
||||||
|
/// starts at 0 and there's no race against a fixed delay. Used when taking
|
||||||
|
/// over playback from a remote session.
|
||||||
|
pub fn play_queue_from(
|
||||||
|
&self,
|
||||||
|
items: Vec<MediaItem>,
|
||||||
|
start_index: usize,
|
||||||
|
start_position: Option<f64>,
|
||||||
|
) -> Result<(), PlayerError> {
|
||||||
|
debug!(
|
||||||
|
"[PlayerController] play_queue: {} items, starting at index {} (resume: {:?})",
|
||||||
|
items.len(),
|
||||||
|
start_index,
|
||||||
|
start_position
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset autoplay counter on manual queue start
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock_safe();
|
||||||
queue.set_queue(items, start_index);
|
queue.set_queue(items, start_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play the current item (without modifying the queue we just set)
|
// Play the current item (without modifying the queue we just set)
|
||||||
if let Some(item) = self.queue.lock().unwrap().current().cloned() {
|
if let Some(item) = self.queue.lock_safe().current().cloned() {
|
||||||
self.load_and_play(&item)?;
|
self.load_and_play(&item)?;
|
||||||
|
|
||||||
|
// Resume from the requested position. Seeking right after load (while
|
||||||
|
// the backend lock is no longer held) avoids the start-at-0-then-jump
|
||||||
|
// race that a delayed frontend seek suffers from.
|
||||||
|
if let Some(position) = start_position {
|
||||||
|
if position > 0.5 {
|
||||||
|
self.seek(position)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the queue without starting local playback.
|
||||||
|
///
|
||||||
|
/// Used when we're controlling a remote session: the tracks play on the
|
||||||
|
/// remote device, but we keep the local queue in sync so the UI reflects
|
||||||
|
/// what's playing and a later transfer-to-local has the queue to resume.
|
||||||
|
pub fn set_queue(&self, items: Vec<MediaItem>, start_index: usize) -> Result<(), PlayerError> {
|
||||||
|
debug!(
|
||||||
|
"[PlayerController] set_queue (no local playback): {} items, index {}",
|
||||||
|
items.len(),
|
||||||
|
start_index
|
||||||
|
);
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
let mut queue = self.queue.lock_safe();
|
||||||
|
queue.set_queue(items, start_index);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Play/resume playback
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
debug!("[PlayerController] play");
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.play()
|
backend.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
pub fn pause(&self) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.pause()
|
backend.pause()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle play/pause
|
/// Toggle play/pause
|
||||||
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
if backend.state().is_playing() {
|
if backend.state().is_playing() {
|
||||||
backend.pause()
|
backend.pause()
|
||||||
} else {
|
} else {
|
||||||
@@ -305,16 +405,16 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Get current playback info before stopping
|
// Get current playback info before stopping
|
||||||
let jellyfin_id = {
|
let jellyfin_id = {
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock_safe();
|
||||||
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
|
||||||
};
|
};
|
||||||
|
|
||||||
let position_ticks = {
|
let position_ticks = {
|
||||||
let backend = self.backend.lock().unwrap();
|
let backend = self.backend.lock_safe();
|
||||||
(backend.position() * 10_000_000.0) as i64
|
(backend.position() * 10_000_000.0) as i64
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.stop()?;
|
backend.stop()?;
|
||||||
drop(backend);
|
drop(backend);
|
||||||
|
|
||||||
@@ -374,8 +474,11 @@ impl PlayerController {
|
|||||||
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
||||||
/// from triggering when the current track's EndFile event fires
|
/// from triggering when the current track's EndFile event fires
|
||||||
pub fn next(&self) -> Result<(), PlayerError> {
|
pub fn next(&self) -> Result<(), PlayerError> {
|
||||||
|
// Reset autoplay counter on manual skip
|
||||||
|
self.reset_autoplay_count();
|
||||||
|
|
||||||
let next_item = {
|
let next_item = {
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock_safe();
|
||||||
queue.next().cloned()
|
queue.next().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,9 +497,11 @@ impl PlayerController {
|
|||||||
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
/// Note: load_and_play sets EndReason::NewTrackLoaded to prevent autoplay
|
||||||
/// from triggering when the current track's EndFile event fires
|
/// from triggering when the current track's EndFile event fires
|
||||||
pub fn previous(&self) -> Result<(), PlayerError> {
|
pub fn previous(&self) -> Result<(), PlayerError> {
|
||||||
|
// Reset autoplay counter on manual skip
|
||||||
|
self.reset_autoplay_count();
|
||||||
// If we're more than 3 seconds in, restart current track
|
// If we're more than 3 seconds in, restart current track
|
||||||
{
|
{
|
||||||
let backend = self.backend.lock().unwrap();
|
let backend = self.backend.lock_safe();
|
||||||
if backend.position() > 3.0 {
|
if backend.position() > 3.0 {
|
||||||
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
debug!("[PlayerController] previous: restarting current track (position > 3s)");
|
||||||
drop(backend);
|
drop(backend);
|
||||||
@@ -405,7 +510,7 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let prev_item = {
|
let prev_item = {
|
||||||
let mut queue = self.queue.lock().unwrap();
|
let mut queue = self.queue.lock_safe();
|
||||||
queue.previous().cloned()
|
queue.previous().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,40 +525,40 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Seek to a position in seconds
|
/// Seek to a position in seconds
|
||||||
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.seek(position)
|
backend.seek(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set volume (0.0 - 1.0)
|
/// Set volume (0.0 - 1.0)
|
||||||
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
|
||||||
self.backend.lock().unwrap().set_volume(volume)
|
self.backend.lock_safe().set_volume(volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active audio track by stream index
|
/// Set the active audio track by stream index
|
||||||
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.set_audio_track(stream_index)
|
backend.set_audio_track(stream_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||||
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||||
let mut backend = self.backend.lock().unwrap();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.set_subtitle_track(stream_index)
|
backend.set_subtitle_track(stream_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current state
|
/// Get current state
|
||||||
pub fn state(&self) -> PlayerState {
|
pub fn state(&self) -> PlayerState {
|
||||||
self.backend.lock().unwrap().state()
|
self.backend.lock_safe().state()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current position
|
/// Get current position
|
||||||
pub fn position(&self) -> f64 {
|
pub fn position(&self) -> f64 {
|
||||||
self.backend.lock().unwrap().position()
|
self.backend.lock_safe().position()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get duration
|
/// Get duration
|
||||||
pub fn duration(&self) -> Option<f64> {
|
pub fn duration(&self) -> Option<f64> {
|
||||||
self.backend.lock().unwrap().duration()
|
self.backend.lock_safe().duration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get queue reference
|
/// Get queue reference
|
||||||
@@ -463,27 +568,27 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Toggle shuffle
|
/// Toggle shuffle
|
||||||
pub fn toggle_shuffle(&self) {
|
pub fn toggle_shuffle(&self) {
|
||||||
self.queue.lock().unwrap().toggle_shuffle();
|
self.queue.lock_safe().toggle_shuffle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle repeat mode
|
/// Cycle repeat mode
|
||||||
pub fn cycle_repeat(&self) {
|
pub fn cycle_repeat(&self) {
|
||||||
self.queue.lock().unwrap().cycle_repeat();
|
self.queue.lock_safe().cycle_repeat();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if shuffle is enabled
|
/// Check if shuffle is enabled
|
||||||
pub fn is_shuffle(&self) -> bool {
|
pub fn is_shuffle(&self) -> bool {
|
||||||
self.queue.lock().unwrap().is_shuffle()
|
self.queue.lock_safe().is_shuffle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get repeat mode
|
/// Get repeat mode
|
||||||
pub fn repeat_mode(&self) -> RepeatMode {
|
pub fn repeat_mode(&self) -> RepeatMode {
|
||||||
self.queue.lock().unwrap().repeat_mode()
|
self.queue.lock_safe().repeat_mode()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current volume (0.0 - 1.0)
|
/// Get current volume (0.0 - 1.0)
|
||||||
pub fn volume(&self) -> f32 {
|
pub fn volume(&self) -> f32 {
|
||||||
self.backend.lock().unwrap().volume()
|
self.backend.lock_safe().volume()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if muted
|
/// Check if muted
|
||||||
@@ -493,35 +598,35 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Set audio settings (crossfade, gapless, normalization)
|
/// Set audio settings (crossfade, gapless, normalization)
|
||||||
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||||
self.backend.lock().unwrap().set_audio_settings(settings)
|
self.backend.lock_safe().set_audio_settings(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current audio settings
|
/// Get current audio settings
|
||||||
pub fn audio_settings(&self) -> AudioSettings {
|
pub fn audio_settings(&self) -> AudioSettings {
|
||||||
self.backend.lock().unwrap().audio_settings()
|
self.backend.lock_safe().audio_settings()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Sleep Timer Methods =====
|
// ===== Sleep Timer Methods =====
|
||||||
|
|
||||||
/// Set the event emitter for notifications
|
/// Set the event emitter for notifications
|
||||||
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
|
||||||
let mut event_emitter = self.event_emitter.lock().unwrap();
|
let mut event_emitter = self.event_emitter.lock_safe();
|
||||||
*event_emitter = Some(emitter);
|
*event_emitter = Some(emitter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the event emitter
|
/// Get the event emitter
|
||||||
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
|
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
|
||||||
self.event_emitter.lock().unwrap().clone()
|
self.event_emitter.lock_safe().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get sleep timer state
|
/// Get sleep timer state
|
||||||
pub fn sleep_timer_state(&self) -> SleepTimerState {
|
pub fn sleep_timer_state(&self) -> SleepTimerState {
|
||||||
self.sleep_timer.lock().unwrap().clone()
|
self.sleep_timer.lock_safe().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set sleep timer mode (in-memory only, not persisted)
|
/// Set sleep timer mode (in-memory only, not persisted)
|
||||||
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
|
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
|
||||||
let mut timer = self.sleep_timer.lock().unwrap();
|
let mut timer = self.sleep_timer.lock_safe();
|
||||||
timer.mode = mode.clone();
|
timer.mode = mode.clone();
|
||||||
if let SleepTimerMode::Time { end_time } = mode {
|
if let SleepTimerMode::Time { end_time } = mode {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
@@ -544,17 +649,39 @@ impl PlayerController {
|
|||||||
fn start_timer_thread(&self) {
|
fn start_timer_thread(&self) {
|
||||||
let sleep_timer = self.sleep_timer.clone();
|
let sleep_timer = self.sleep_timer.clone();
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
|
let backend = self.backend.clone();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
let mut timer = sleep_timer.lock().unwrap();
|
let mut timer = sleep_timer.lock_safe();
|
||||||
if timer.is_active() {
|
if timer.is_active() {
|
||||||
timer.update_remaining_seconds();
|
timer.update_remaining_seconds();
|
||||||
|
|
||||||
|
// Time-based timer expired: stop playback
|
||||||
|
if matches!(timer.mode, SleepTimerMode::Time { .. }) && timer.remaining_seconds == 0 {
|
||||||
|
debug!("[SleepTimer] Time-based timer expired, stopping playback");
|
||||||
|
timer.cancel();
|
||||||
|
|
||||||
|
// Emit cancelled state
|
||||||
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||||
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
|
mode: SleepTimerMode::Off,
|
||||||
|
remaining_seconds: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop(timer);
|
||||||
|
|
||||||
|
// Stop the backend
|
||||||
|
if let Err(e) = backend.lock_safe().stop() {
|
||||||
|
error!("[SleepTimer] Failed to stop playback: {}", e);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit update event
|
// Emit update event
|
||||||
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
mode: timer.mode.clone(),
|
mode: timer.mode.clone(),
|
||||||
remaining_seconds: timer.remaining_seconds,
|
remaining_seconds: timer.remaining_seconds,
|
||||||
@@ -568,9 +695,9 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Emit sleep timer changed event to frontend
|
/// Emit sleep timer changed event to frontend
|
||||||
fn emit_sleep_timer_changed(&self) {
|
fn emit_sleep_timer_changed(&self) {
|
||||||
let timer = self.sleep_timer.lock().unwrap().clone();
|
let timer = self.sleep_timer.lock_safe().clone();
|
||||||
|
|
||||||
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
|
||||||
mode: timer.mode,
|
mode: timer.mode,
|
||||||
remaining_seconds: timer.remaining_seconds,
|
remaining_seconds: timer.remaining_seconds,
|
||||||
@@ -580,12 +707,12 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Emit queue changed event to frontend
|
/// Emit queue changed event to frontend
|
||||||
pub fn emit_queue_changed(&self) {
|
pub fn emit_queue_changed(&self) {
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock_safe();
|
||||||
|
|
||||||
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
|
||||||
queue.items().len(), queue.current_index());
|
queue.items().len(), queue.current_index());
|
||||||
|
|
||||||
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::QueueChanged {
|
emitter.emit(PlayerStatusEvent::QueueChanged {
|
||||||
items: queue.items().to_vec(),
|
items: queue.items().to_vec(),
|
||||||
current_index: queue.current_index(),
|
current_index: queue.current_index(),
|
||||||
@@ -603,19 +730,19 @@ impl PlayerController {
|
|||||||
|
|
||||||
/// Get autoplay settings
|
/// Get autoplay settings
|
||||||
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
pub fn autoplay_settings(&self) -> AutoplaySettings {
|
||||||
self.autoplay_settings.lock().unwrap().clone()
|
self.autoplay_settings.lock_safe().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
/// Set autoplay settings (in-memory only, persistence handled by command layer)
|
||||||
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
|
||||||
let validated = settings.with_validated_countdown();
|
let validated = settings.with_validated_countdown();
|
||||||
*self.autoplay_settings.lock().unwrap() = validated;
|
*self.autoplay_settings.lock_safe() = validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel active autoplay countdown
|
/// Cancel active autoplay countdown
|
||||||
pub fn cancel_autoplay_countdown(&self) {
|
pub fn cancel_autoplay_countdown(&self) {
|
||||||
if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
|
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
|
||||||
*cancel_flag.lock().unwrap() = true;
|
*cancel_flag.lock_safe() = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +785,7 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let current_item = {
|
let current_item = {
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock_safe();
|
||||||
queue.current().cloned()
|
queue.current().cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -668,14 +795,24 @@ impl PlayerController {
|
|||||||
|
|
||||||
// Check sleep timer state
|
// Check sleep timer state
|
||||||
let timer_mode = {
|
let timer_mode = {
|
||||||
let timer = self.sleep_timer.lock().unwrap();
|
let timer = self.sleep_timer.lock_safe();
|
||||||
timer.mode.clone()
|
timer.mode.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
match &timer_mode {
|
match &timer_mode {
|
||||||
|
SleepTimerMode::Time { end_time } => {
|
||||||
|
// If time has expired, stop instead of playing next
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
if now >= *end_time {
|
||||||
|
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
|
||||||
|
self.sleep_timer.lock_safe().cancel();
|
||||||
|
self.emit_sleep_timer_changed();
|
||||||
|
return Ok(AutoplayDecision::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
SleepTimerMode::EndOfTrack => {
|
SleepTimerMode::EndOfTrack => {
|
||||||
// Stop at end of track
|
// Stop at end of track
|
||||||
self.sleep_timer.lock().unwrap().cancel();
|
self.sleep_timer.lock_safe().cancel();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
@@ -685,7 +822,7 @@ impl PlayerController {
|
|||||||
&& self.is_episode_item(¤t).await;
|
&& self.is_episode_item(¤t).await;
|
||||||
|
|
||||||
if is_episode {
|
if is_episode {
|
||||||
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
|
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||||
self.emit_sleep_timer_changed();
|
self.emit_sleep_timer_changed();
|
||||||
|
|
||||||
if should_stop {
|
if should_stop {
|
||||||
@@ -699,14 +836,31 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For video episodes, fetch next episode and show popup
|
// For video episodes, fetch next episode and show popup
|
||||||
|
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
||||||
|
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
||||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||||
if let Some(next_ep) = self.fetch_next_episode_for_item(¤t).await? {
|
let repo = self.repository.lock_safe().clone();
|
||||||
let settings = self.autoplay_settings.lock().unwrap().clone();
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||||
|
let next_ep_result = if let Some(repo) = &repo {
|
||||||
|
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
|
||||||
|
} else {
|
||||||
|
debug!("[PlayerController] No repository available for audio-path episode lookup");
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(next_ep) = next_ep_result {
|
||||||
|
let settings = self.autoplay_settings.lock_safe().clone();
|
||||||
|
|
||||||
|
// Check if auto-play episode limit is reached
|
||||||
|
let limit_reached = self.increment_autoplay_count();
|
||||||
|
if limit_reached {
|
||||||
|
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||||
current_episode: next_ep.0, // Repository MediaItem
|
current_episode: next_ep.0, // Repository MediaItem
|
||||||
next_episode: next_ep.1,
|
next_episode: next_ep.1,
|
||||||
countdown_seconds: settings.countdown_seconds,
|
countdown_seconds: settings.countdown_seconds,
|
||||||
auto_advance: settings.enabled,
|
auto_advance: settings.enabled && !limit_reached,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// No next episode found
|
// No next episode found
|
||||||
@@ -715,7 +869,7 @@ impl PlayerController {
|
|||||||
|
|
||||||
// For audio/movies, check if there's a next track in the queue
|
// For audio/movies, check if there's a next track in the queue
|
||||||
let has_next = {
|
let has_next = {
|
||||||
let queue = self.queue.lock().unwrap();
|
let queue = self.queue.lock_safe();
|
||||||
queue.has_next()
|
queue.has_next()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -728,6 +882,78 @@ impl PlayerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle video playback ended from HTML5 video element.
|
||||||
|
///
|
||||||
|
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||||
|
/// queue has no knowledge of the video item. This method bypasses the
|
||||||
|
/// queue lookup and end_reason check, using the provided Jellyfin item ID
|
||||||
|
/// to look up the item and check for next episodes.
|
||||||
|
pub async fn on_video_playback_ended(
|
||||||
|
&self,
|
||||||
|
item_id: &str,
|
||||||
|
repo: Arc<dyn crate::repository::MediaRepository>,
|
||||||
|
) -> Result<AutoplayDecision, String> {
|
||||||
|
// Clear any stale end_reason (e.g., UserStop from stopping audio before video)
|
||||||
|
let stale_reason = self.take_end_reason();
|
||||||
|
if stale_reason.is_some() {
|
||||||
|
debug!("[PlayerController] Cleared stale end_reason for video: {:?}", stale_reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[PlayerController] on_video_playback_ended: item_id={}", item_id);
|
||||||
|
|
||||||
|
// Check sleep timer state
|
||||||
|
let timer_mode = {
|
||||||
|
let timer = self.sleep_timer.lock_safe();
|
||||||
|
timer.mode.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
match &timer_mode {
|
||||||
|
SleepTimerMode::Time { end_time } => {
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
if now >= *end_time {
|
||||||
|
debug!("[PlayerController] Time-based sleep timer expired at video end");
|
||||||
|
self.sleep_timer.lock_safe().cancel();
|
||||||
|
self.emit_sleep_timer_changed();
|
||||||
|
return Ok(AutoplayDecision::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SleepTimerMode::EndOfTrack => {
|
||||||
|
self.sleep_timer.lock_safe().cancel();
|
||||||
|
self.emit_sleep_timer_changed();
|
||||||
|
return Ok(AutoplayDecision::Stop);
|
||||||
|
}
|
||||||
|
SleepTimerMode::Episodes { .. } => {
|
||||||
|
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||||
|
self.emit_sleep_timer_changed();
|
||||||
|
if should_stop {
|
||||||
|
return Ok(AutoplayDecision::Stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch next episode for the video that just ended
|
||||||
|
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
|
||||||
|
let settings = self.autoplay_settings.lock_safe().clone();
|
||||||
|
|
||||||
|
let limit_reached = self.increment_autoplay_count();
|
||||||
|
if limit_reached {
|
||||||
|
debug!("[PlayerController] Auto-play episode limit reached ({} episodes)", settings.max_episodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||||
|
current_episode: next_ep.0,
|
||||||
|
next_episode: next_ep.1,
|
||||||
|
countdown_seconds: settings.countdown_seconds,
|
||||||
|
auto_advance: settings.enabled && !limit_reached,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No next episode found
|
||||||
|
debug!("[PlayerController] No next episode found for {}", item_id);
|
||||||
|
Ok(AutoplayDecision::Stop)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a media item is an episode (has Jellyfin ID to query)
|
/// Check if a media item is an episode (has Jellyfin ID to query)
|
||||||
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||||
// For now, assume video items are episodes
|
// For now, assume video items are episodes
|
||||||
@@ -735,34 +961,63 @@ impl PlayerController {
|
|||||||
item.media_type == MediaType::Video
|
item.media_type == MediaType::Video
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch next episode for a series (using Repository)
|
/// Fetch next episode for a series by looking up the season's episodes
|
||||||
async fn fetch_next_episode_for_item(&self, current: &MediaItem) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
/// sorted by index number and picking the one after the current episode.
|
||||||
let repo = self.repository.lock().unwrap().clone();
|
///
|
||||||
let Some(repo) = repo else {
|
/// This is deterministic and doesn't depend on Jellyfin's "Next Up" API
|
||||||
return Ok(None);
|
/// (which relies on watch history that may not be updated yet due to
|
||||||
};
|
/// the async nature of playback progress reporting).
|
||||||
|
async fn fetch_next_episode_for_item(
|
||||||
|
&self,
|
||||||
|
item_id: &str,
|
||||||
|
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||||
|
) -> Result<Option<(crate::repository::types::MediaItem, crate::repository::types::MediaItem)>, String> {
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
|
||||||
let jellyfin_id = current.jellyfin_id()
|
// Get the current item details from repository
|
||||||
.ok_or_else(|| "No Jellyfin ID for current item".to_string())?;
|
let current_repo_item = repo.get_item(item_id)
|
||||||
|
|
||||||
// First, get the current item details from repository
|
|
||||||
let current_repo_item = repo.get_item(jellyfin_id)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
.map_err(|e| format!("Failed to get current item: {}", e))?;
|
||||||
|
|
||||||
let series_id = current_repo_item.series_id.clone()
|
// Need season_id to fetch sibling episodes
|
||||||
.ok_or_else(|| "Current item is not an episode".to_string())?;
|
let season_id = match ¤t_repo_item.season_id {
|
||||||
|
Some(sid) => sid.clone(),
|
||||||
// Fetch next up episodes for this series
|
None => {
|
||||||
let next_episodes = repo.get_next_up_episodes(Some(&series_id), Some(1))
|
debug!("[PlayerController] Current item has no season_id, cannot find next episode");
|
||||||
.await
|
return Ok(None);
|
||||||
.map_err(|e| format!("Failed to fetch next episodes: {}", e))?;
|
|
||||||
|
|
||||||
if let Some(next) = next_episodes.first() {
|
|
||||||
// Verify it's not the same episode
|
|
||||||
if next.id != current_repo_item.id {
|
|
||||||
return Ok(Some((current_repo_item, next.clone())));
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all episodes in the season sorted by episode number
|
||||||
|
let options = GetItemsOptions {
|
||||||
|
sort_by: Some("IndexNumber".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(500),
|
||||||
|
include_item_types: Some(vec!["Episode".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = repo.get_items(&season_id, Some(options))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch season episodes: {}", e))?;
|
||||||
|
|
||||||
|
// Sort client-side by index_number to ensure correct ordering
|
||||||
|
// (offline repo ignores sort_by and sorts by sort_name instead)
|
||||||
|
let mut episodes = result.items;
|
||||||
|
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||||
|
debug!("[PlayerController] Season has {} episodes, looking for next after {}", episodes.len(), current_repo_item.id);
|
||||||
|
|
||||||
|
// Find the current episode by ID and return the next one
|
||||||
|
if let Some(current_idx) = episodes.iter().position(|e| e.id == current_repo_item.id) {
|
||||||
|
if current_idx + 1 < episodes.len() {
|
||||||
|
let next = &episodes[current_idx + 1];
|
||||||
|
debug!("[PlayerController] Found next episode: {} (index {})", next.name, current_idx + 1);
|
||||||
|
return Ok(Some((current_repo_item, next.clone())));
|
||||||
|
} else {
|
||||||
|
debug!("[PlayerController] Current episode is the last in the season");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("[PlayerController] Current episode not found in season episodes");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -772,7 +1027,7 @@ impl PlayerController {
|
|||||||
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
|
||||||
// Create cancellation flag
|
// Create cancellation flag
|
||||||
let cancel_flag = Arc::new(Mutex::new(false));
|
let cancel_flag = Arc::new(Mutex::new(false));
|
||||||
*self.countdown_cancel.lock().unwrap() = Some(cancel_flag.clone());
|
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
|
||||||
|
|
||||||
let event_emitter = self.event_emitter.clone();
|
let event_emitter = self.event_emitter.clone();
|
||||||
|
|
||||||
@@ -783,7 +1038,7 @@ impl PlayerController {
|
|||||||
std::thread::sleep(Duration::from_secs(1));
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
|
||||||
// Check cancellation
|
// Check cancellation
|
||||||
if *cancel_flag.lock().unwrap() {
|
if *cancel_flag.lock_safe() {
|
||||||
log::info!("[PlayerController] Autoplay countdown cancelled");
|
log::info!("[PlayerController] Autoplay countdown cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -791,21 +1046,15 @@ impl PlayerController {
|
|||||||
remaining -= 1;
|
remaining -= 1;
|
||||||
|
|
||||||
// Emit countdown tick event
|
// Emit countdown tick event
|
||||||
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::CountdownTick {
|
emitter.emit(PlayerStatusEvent::CountdownTick {
|
||||||
remaining_seconds: remaining,
|
remaining_seconds: remaining,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Countdown finished - emit final tick at 0
|
// Countdown finished (final tick at 0 was already emitted inside the loop)
|
||||||
log::info!("[PlayerController] Autoplay countdown finished");
|
log::info!("[PlayerController] Autoplay countdown finished");
|
||||||
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
|
|
||||||
emitter.emit(PlayerStatusEvent::CountdownTick {
|
|
||||||
remaining_seconds: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The frontend can listen for countdown reaching 0 and trigger playback
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -898,7 +1147,7 @@ mod tests {
|
|||||||
// Verify initial state
|
// Verify initial state
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
|
||||||
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
|
||||||
@@ -910,7 +1159,7 @@ mod tests {
|
|||||||
// Verify queue is intact and index advanced
|
// Verify queue is intact and index advanced
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
|
||||||
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
|
||||||
@@ -929,7 +1178,7 @@ mod tests {
|
|||||||
// Verify queue still intact and index advanced again
|
// Verify queue still intact and index advanced again
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||||
@@ -942,7 +1191,7 @@ mod tests {
|
|||||||
// Verify we're at the last item
|
// Verify we're at the last item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
|
||||||
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
|
||||||
@@ -964,7 +1213,7 @@ mod tests {
|
|||||||
// Verify we're at the last item
|
// Verify we're at the last item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,7 +1224,7 @@ mod tests {
|
|||||||
// Verify queue is still intact
|
// Verify queue is still intact
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
|
||||||
// When we skip past the end, the queue index should stay at the last item
|
// When we skip past the end, the queue index should stay at the last item
|
||||||
// or become None (depending on implementation)
|
// or become None (depending on implementation)
|
||||||
@@ -1004,7 +1253,7 @@ mod tests {
|
|||||||
// Verify we wrapped to the first item
|
// Verify we wrapped to the first item
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
|
||||||
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
|
||||||
@@ -1023,7 +1272,7 @@ mod tests {
|
|||||||
// Verify starting position
|
// Verify starting position
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,7 +1282,7 @@ mod tests {
|
|||||||
// Verify queue is intact and index moved back
|
// Verify queue is intact and index moved back
|
||||||
{
|
{
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
let queue_lock = queue.lock().unwrap();
|
let queue_lock = queue.lock_safe();
|
||||||
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
|
||||||
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
|
||||||
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
|
||||||
@@ -1130,6 +1379,44 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resuming a queue at a position seeks the starting track immediately.
|
||||||
|
/// Regression guard for taking over a remote session: the local player must
|
||||||
|
/// pick up where the remote left off, not restart from 0.
|
||||||
|
#[test]
|
||||||
|
fn test_play_queue_from_resumes_at_position() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let items = create_test_items(3);
|
||||||
|
|
||||||
|
controller.play_queue_from(items, 1, Some(42.5)).unwrap();
|
||||||
|
|
||||||
|
{
|
||||||
|
let queue = controller.queue();
|
||||||
|
let queue_lock = queue.lock_safe();
|
||||||
|
assert_eq!(queue_lock.current_index(), Some(1), "Should start at index 1");
|
||||||
|
}
|
||||||
|
assert_eq!(controller.position(), 42.5, "Should resume at the requested position");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A None / near-zero start position starts the track from the beginning.
|
||||||
|
#[test]
|
||||||
|
fn test_play_queue_from_without_position_starts_at_zero() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue_from(create_test_items(2), 0, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(controller.position(), 0.0, "No resume position starts at 0");
|
||||||
|
|
||||||
|
controller
|
||||||
|
.play_queue_from(create_test_items(2), 0, Some(0.2))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
controller.position(),
|
||||||
|
0.0,
|
||||||
|
"Sub-threshold resume position is ignored (starts at 0)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_seek_to_zero() {
|
fn test_seek_to_zero() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
@@ -1202,7 +1489,7 @@ mod tests {
|
|||||||
|
|
||||||
// Set sleep timer to end of track
|
// Set sleep timer to end of track
|
||||||
{
|
{
|
||||||
let mut timer = controller.sleep_timer.lock().unwrap();
|
let mut timer = controller.sleep_timer.lock_safe();
|
||||||
timer.mode = SleepTimerMode::EndOfTrack;
|
timer.mode = SleepTimerMode::EndOfTrack;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,7 +1504,7 @@ mod tests {
|
|||||||
|
|
||||||
// Verify timer was cancelled
|
// Verify timer was cancelled
|
||||||
{
|
{
|
||||||
let timer = controller.sleep_timer.lock().unwrap();
|
let timer = controller.sleep_timer.lock_safe();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(timer.mode, SleepTimerMode::Off),
|
matches!(timer.mode, SleepTimerMode::Off),
|
||||||
"Sleep timer should be cancelled after EndOfTrack"
|
"Sleep timer should be cancelled after EndOfTrack"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use super::backend::{PlayerBackend, PlayerError};
|
use super::backend::{PlayerBackend, PlayerError};
|
||||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||||
@@ -190,7 +191,7 @@ impl MpvBackend {
|
|||||||
libmpv::events::Event::PlaybackRestart => {
|
libmpv::events::Event::PlaybackRestart => {
|
||||||
debug!("[MpvBackend] Playback started/resumed");
|
debug!("[MpvBackend] Playback started/resumed");
|
||||||
|
|
||||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||||
|
|
||||||
if let Some(emitter) = &event_emitter {
|
if let Some(emitter) = &event_emitter {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
@@ -202,7 +203,7 @@ impl MpvBackend {
|
|||||||
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
|
||||||
// Handle pause state changes
|
// Handle pause state changes
|
||||||
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
|
||||||
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
|
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
|
||||||
|
|
||||||
if let Some(emitter) = &event_emitter {
|
if let Some(emitter) = &event_emitter {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
@@ -308,7 +309,7 @@ impl MpvBackend {
|
|||||||
if !is_paused {
|
if !is_paused {
|
||||||
// Throttled progress reporting (every 30s)
|
// Throttled progress reporting (every 30s)
|
||||||
let jellyfin_id = {
|
let jellyfin_id = {
|
||||||
let state = state_for_position.lock().unwrap();
|
let state = state_for_position.lock_safe();
|
||||||
state.current_media.as_ref()
|
state.current_media.as_ref()
|
||||||
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
|
||||||
};
|
};
|
||||||
@@ -376,7 +377,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
{
|
{
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock_safe();
|
||||||
state.current_media = Some(media.clone());
|
state.current_media = Some(media.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +423,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to stop: {:?}", e),
|
message: format!("Failed to stop: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock_safe();
|
||||||
state.current_media = None;
|
state.current_media = None;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -460,7 +461,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
message: format!("Failed to set volume: {:?}", e),
|
message: format!("Failed to set volume: {:?}", e),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock_safe();
|
||||||
state.volume = clamped;
|
state.volume = clamped;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -480,7 +481,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn state(&self) -> PlayerState {
|
fn state(&self) -> PlayerState {
|
||||||
let state = self.state.lock().unwrap();
|
let state = self.state.lock_safe();
|
||||||
|
|
||||||
if let Some(ref media) = state.current_media {
|
if let Some(ref media) = state.current_media {
|
||||||
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
|
||||||
@@ -506,7 +507,7 @@ impl PlayerBackend for MpvBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn volume(&self) -> f32 {
|
fn volume(&self) -> f32 {
|
||||||
let state = self.state.lock().unwrap();
|
let state = self.state.lock_safe();
|
||||||
state.volume
|
state.volume
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use super::media::{MediaItem, MediaSource, QueueContext};
|
|||||||
/// Repeat mode for the queue
|
/// Repeat mode for the queue
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-005
|
/// TRACES: UR-005 | DR-005
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RepeatMode {
|
pub enum RepeatMode {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -18,7 +18,7 @@ pub enum RepeatMode {
|
|||||||
/// Queue manager for playlist functionality
|
/// Queue manager for playlist functionality
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005, UR-015 | DR-005, DR-020
|
/// TRACES: UR-005, UR-015 | DR-005, DR-020
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct QueueManager {
|
pub struct QueueManager {
|
||||||
/// All items in the queue
|
/// All items in the queue
|
||||||
items: Vec<MediaItem>,
|
items: Vec<MediaItem>,
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Video seek strategy decision logic.
|
||||||
|
//!
|
||||||
|
//! This is pure logic, extracted from the command layer so it can be unit-tested
|
||||||
|
//! in the player core. The `player_seek_video` command translates the resulting
|
||||||
|
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
|
||||||
|
|
||||||
|
/// Seek strategy for video playback, derived from a stream's characteristics.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum VideoSeekStrategy {
|
||||||
|
/// Local file - always use native seek on backend
|
||||||
|
LocalNativeSeek,
|
||||||
|
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
|
||||||
|
Html5NativeSeek,
|
||||||
|
/// HLS or direct stream with native backend - backend handles seek
|
||||||
|
BackendNativeSeek,
|
||||||
|
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
|
||||||
|
Html5ReloadStream,
|
||||||
|
/// Transcoded non-HLS with native backend - reload stream, backend handles
|
||||||
|
BackendReloadStream,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the video seek strategy based on stream characteristics.
|
||||||
|
///
|
||||||
|
/// This is a pure function extracted for testability.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `is_local` - Whether the file is a local download
|
||||||
|
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
|
||||||
|
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||||
|
/// * `use_html5` - Whether frontend is using HTML5 video element
|
||||||
|
pub fn determine_video_seek_strategy(
|
||||||
|
is_local: bool,
|
||||||
|
is_hls: bool,
|
||||||
|
needs_transcoding: bool,
|
||||||
|
use_html5: bool,
|
||||||
|
) -> VideoSeekStrategy {
|
||||||
|
// Local files always support native seeking via backend
|
||||||
|
if is_local {
|
||||||
|
return VideoSeekStrategy::LocalNativeSeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLS streams and direct play (non-transcoded) support native seeking
|
||||||
|
if is_hls || !needs_transcoding {
|
||||||
|
if use_html5 {
|
||||||
|
// HTML5 backend - frontend handles seeking via videoElement.currentTime
|
||||||
|
// We don't call backend.seek() because video is in HTML5 element, not in MPV
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
} else {
|
||||||
|
// Native backend (MPV) - backend handles seeking
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Transcoded non-HLS streams need server-side seek (reload from new position)
|
||||||
|
if use_html5 {
|
||||||
|
VideoSeekStrategy::Html5ReloadStream
|
||||||
|
} else {
|
||||||
|
VideoSeekStrategy::BackendReloadStream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test video seek strategy for local files
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_local_file() {
|
||||||
|
// Local files always use native backend seek regardless of other flags
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, false, false, false),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, false, false, true),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(true, true, true, true),
|
||||||
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for HLS streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_hls_stream() {
|
||||||
|
// HLS with HTML5 - frontend handles seek, don't call backend
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, false, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
// HLS with native backend - backend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, false, false),
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
);
|
||||||
|
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, true, true, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for direct play (non-transcoded) streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_direct_play() {
|
||||||
|
// Direct play with HTML5 - frontend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, false, true),
|
||||||
|
VideoSeekStrategy::Html5NativeSeek
|
||||||
|
);
|
||||||
|
// Direct play with native backend - backend handles seek
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, false, false),
|
||||||
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test video seek strategy for transcoded non-HLS streams
|
||||||
|
#[test]
|
||||||
|
fn test_seek_strategy_transcoded_non_hls() {
|
||||||
|
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, true, true),
|
||||||
|
VideoSeekStrategy::Html5ReloadStream
|
||||||
|
);
|
||||||
|
// Transcoded non-HLS with native backend - need to reload stream, backend handles
|
||||||
|
assert_eq!(
|
||||||
|
determine_video_seek_strategy(false, false, true, false),
|
||||||
|
VideoSeekStrategy::BackendReloadStream
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
|
||||||
|
/// This was the bug causing "Raw(-10)" errors
|
||||||
|
#[test]
|
||||||
|
fn test_hls_html5_does_not_use_backend_seek() {
|
||||||
|
let strategy = determine_video_seek_strategy(false, true, false, true);
|
||||||
|
// Should be Html5NativeSeek, NOT BackendNativeSeek
|
||||||
|
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
|
||||||
|
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 SoftwareArchitecture.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;
|
||||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use super::media::MediaItem;
|
use super::media::MediaItem;
|
||||||
|
|
||||||
/// Media session type tracking the high-level playback context
|
/// Media session type tracking the high-level playback context
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum MediaSessionType {
|
pub enum MediaSessionType {
|
||||||
/// No active session - browsing library
|
/// No active session - browsing library
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
/// Sleep timer mode - determines when playback should stop
|
/// Sleep timer mode - determines when playback should stop
|
||||||
/// TRACES: UR-026 | DR-029
|
/// TRACES: UR-026 | DR-029
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
pub enum SleepTimerMode {
|
pub enum SleepTimerMode {
|
||||||
/// Timer is off
|
/// Timer is off
|
||||||
@@ -19,7 +19,7 @@ pub enum SleepTimerMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sleep timer state
|
/// Sleep timer state
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SleepTimerState {
|
pub struct SleepTimerState {
|
||||||
pub mode: SleepTimerMode,
|
pub mode: SleepTimerMode,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use super::media::MediaItem;
|
|||||||
/// Tracks why playback ended to determine autoplay behavior
|
/// Tracks why playback ended to determine autoplay behavior
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-001
|
/// TRACES: UR-005 | DR-001
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum EndReason {
|
pub enum EndReason {
|
||||||
/// Track played to completion (natural end) - trigger autoplay
|
/// Track played to completion (natural end) - trigger autoplay
|
||||||
@@ -23,7 +23,7 @@ pub enum EndReason {
|
|||||||
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
|
/// Player state machine (6 states: Idle, Loading, Playing, Paused, Seeking, Error)
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-001
|
/// TRACES: UR-005 | DR-001
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum PlayerState {
|
pub enum PlayerState {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -61,7 +61,12 @@ pub enum PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerState {
|
impl PlayerState {
|
||||||
/// Get the current playback position if available
|
/// Get the current playback position if available.
|
||||||
|
///
|
||||||
|
/// Note: this is the position snapshot embedded in the state at the last
|
||||||
|
/// state transition, not the live backend position. For an up-to-date
|
||||||
|
/// value use `PlayerController::position()`.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn position(&self) -> Option<f64> {
|
pub fn position(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
PlayerState::Playing { position, .. } => Some(*position),
|
PlayerState::Playing { position, .. } => Some(*position),
|
||||||
@@ -81,3 +86,256 @@ impl PlayerState {
|
|||||||
matches!(self, PlayerState::Paused { .. })
|
matches!(self, PlayerState::Paused { .. })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_finished() {
|
||||||
|
let reason = EndReason::Finished;
|
||||||
|
let json = serde_json::to_string(&reason);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("finished"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_user_skip() {
|
||||||
|
let reason = EndReason::UserSkip;
|
||||||
|
let json = serde_json::to_string(&reason);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("userskip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_user_stop() {
|
||||||
|
let reason = EndReason::UserStop;
|
||||||
|
let json = serde_json::to_string(&reason).unwrap();
|
||||||
|
assert!(json.contains("userstop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_error() {
|
||||||
|
let reason = EndReason::Error;
|
||||||
|
let json = serde_json::to_string(&reason).unwrap();
|
||||||
|
assert!(json.contains("error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_new_track_loaded() {
|
||||||
|
let reason = EndReason::NewTrackLoaded;
|
||||||
|
let json = serde_json::to_string(&reason).unwrap();
|
||||||
|
assert!(json.contains("newtrackloa"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_all_variants() {
|
||||||
|
let reasons = vec![
|
||||||
|
EndReason::Finished,
|
||||||
|
EndReason::UserSkip,
|
||||||
|
EndReason::UserStop,
|
||||||
|
EndReason::Error,
|
||||||
|
EndReason::NewTrackLoaded,
|
||||||
|
];
|
||||||
|
|
||||||
|
for reason in reasons {
|
||||||
|
let json = serde_json::to_string(&reason);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_equality() {
|
||||||
|
let reason1 = EndReason::Finished;
|
||||||
|
let reason2 = EndReason::Finished;
|
||||||
|
assert_eq!(reason1, reason2);
|
||||||
|
|
||||||
|
let reason3 = EndReason::UserSkip;
|
||||||
|
assert_ne!(reason1, reason3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_end_reason_clone() {
|
||||||
|
let reason = EndReason::Finished;
|
||||||
|
let cloned = reason.clone();
|
||||||
|
assert_eq!(reason, cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_idle_default() {
|
||||||
|
let state = PlayerState::default();
|
||||||
|
assert!(matches!(state, PlayerState::Idle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_idle_serialization() {
|
||||||
|
let state = PlayerState::Idle;
|
||||||
|
let json = serde_json::to_string(&state);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("idle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_position_when_idle() {
|
||||||
|
let state = PlayerState::Idle;
|
||||||
|
assert_eq!(state.position(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_is_playing_when_idle() {
|
||||||
|
let state = PlayerState::Idle;
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_is_paused_when_idle() {
|
||||||
|
let state = PlayerState::Idle;
|
||||||
|
assert!(!state.is_paused());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_loading() {
|
||||||
|
let media = create_test_media_item("item-1", "Test Item");
|
||||||
|
let state = PlayerState::Loading { media: media.clone() };
|
||||||
|
assert!(matches!(state, PlayerState::Loading { .. }));
|
||||||
|
assert_eq!(state.position(), None);
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_playing_position() {
|
||||||
|
let media = create_test_media_item("item-2", "Playing Item");
|
||||||
|
let state = PlayerState::Playing {
|
||||||
|
media,
|
||||||
|
position: 45.5,
|
||||||
|
duration: 180.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.is_playing());
|
||||||
|
assert!(!state.is_paused());
|
||||||
|
assert_eq!(state.position(), Some(45.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_paused_position() {
|
||||||
|
let media = create_test_media_item("item-3", "Paused Item");
|
||||||
|
let state = PlayerState::Paused {
|
||||||
|
media,
|
||||||
|
position: 123.75,
|
||||||
|
duration: 300.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(state.is_paused());
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
assert_eq!(state.position(), Some(123.75));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_seeking() {
|
||||||
|
let media = create_test_media_item("item-4", "Seeking Item");
|
||||||
|
let state = PlayerState::Seeking {
|
||||||
|
media,
|
||||||
|
target: 60.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
assert!(!state.is_paused());
|
||||||
|
assert_eq!(state.position(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_error_with_media() {
|
||||||
|
let media = create_test_media_item("item-5", "Error Item");
|
||||||
|
let state = PlayerState::Error {
|
||||||
|
media: Some(media),
|
||||||
|
error: "Playback failed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
assert_eq!(state.position(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_error_without_media() {
|
||||||
|
let state = PlayerState::Error {
|
||||||
|
media: None,
|
||||||
|
error: "Connection lost".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!state.is_playing());
|
||||||
|
assert_eq!(state.position(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_clone() {
|
||||||
|
let state = PlayerState::Idle;
|
||||||
|
let cloned = state.clone();
|
||||||
|
assert!(matches!(cloned, PlayerState::Idle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_playing_serialization() {
|
||||||
|
let media = create_test_media_item("item-6", "Serial Item");
|
||||||
|
let state = PlayerState::Playing {
|
||||||
|
media,
|
||||||
|
position: 30.0,
|
||||||
|
duration: 120.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&state);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("playing"));
|
||||||
|
assert!(serialized.contains("30"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_multiple_positions() {
|
||||||
|
let positions = vec![0.0, 45.5, 100.0, 999.99];
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
|
let media = create_test_media_item("item-test", "Test");
|
||||||
|
let state = PlayerState::Playing {
|
||||||
|
media,
|
||||||
|
position: pos,
|
||||||
|
duration: 1000.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state.position(), Some(pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create test MediaItem instances
|
||||||
|
fn create_test_media_item(id: &str, title: &str) -> MediaItem {
|
||||||
|
MediaItem {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: Some("Video".to_string()),
|
||||||
|
playlist_id: None,
|
||||||
|
duration: Some(100.0),
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: super::super::media::MediaType::Video,
|
||||||
|
source: super::super::media::MediaSource::DirectUrl {
|
||||||
|
url: "http://example.com/media".to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// @req: DR-012 - Local database for media metadata cache
|
// @req: DR-012 - Local database for media metadata cache
|
||||||
// @req: DR-013 - Repository pattern for online/offline data access
|
// @req: DR-013 - Repository pattern for online/offline data access
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::utils::lock::MutexSafe;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -37,6 +39,12 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||||
|
/// Delegates to online repository for connection reuse and proper auth.
|
||||||
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
self.online.download_bytes(url).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Get video stream URL with optional seeking support.
|
/// Get video stream URL with optional seeking support.
|
||||||
/// This method is online-only since offline playback uses local file paths.
|
/// This method is online-only since offline playback uses local file paths.
|
||||||
pub async fn get_video_stream_url(
|
pub async fn get_video_stream_url(
|
||||||
@@ -49,14 +57,87 @@ impl HybridRepository {
|
|||||||
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
self.online.get_video_stream_url(item_id, media_source_id, start_time_seconds, audio_stream_index).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Race cache vs server, return first valid result
|
/// Search only the local SQLite cache (downloaded content).
|
||||||
/// Prefer cache if it has meaningful content, otherwise use server
|
|
||||||
///
|
///
|
||||||
/// Core algorithm of the cache-first parallel racing strategy.
|
/// Fast (100ms timeout) — used to render instant results before the server
|
||||||
/// Runs both cache and server queries concurrently, then:
|
/// responds. Returns an empty result rather than erroring on timeout so the
|
||||||
/// 1. If cache has meaningful content → return cache (fast path)
|
/// caller can still fall through to the server.
|
||||||
/// 2. If cache is empty/stale → return server (fresh data)
|
pub async fn search_cache_only(
|
||||||
/// 3. If server fails → return cache even if empty (offline fallback)
|
&self,
|
||||||
|
query: &str,
|
||||||
|
options: Option<SearchOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
let offline = Arc::clone(&self.offline);
|
||||||
|
let query = query.to_string();
|
||||||
|
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search only the live Jellyfin server (full library).
|
||||||
|
pub async fn search_server_only(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
options: Option<SearchOptions>,
|
||||||
|
) -> Result<SearchResult, RepoError> {
|
||||||
|
self.online.search(query, options).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge cache and server search results into a single de-duplicated list.
|
||||||
|
///
|
||||||
|
/// Ordering: local (cached/downloaded) items first, then server-only items
|
||||||
|
/// appended. On a duplicate `id`, the server's item wins (fresher, more
|
||||||
|
/// complete metadata) but keeps the local item's earlier position.
|
||||||
|
pub fn merge_search_results(cache: SearchResult, server: SearchResult) -> SearchResult {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Index server items by id so we can (a) override duplicates with the
|
||||||
|
// server's metadata and (b) know which server items are brand new.
|
||||||
|
let mut server_by_id: HashMap<String, MediaItem> = HashMap::new();
|
||||||
|
let mut server_order: Vec<String> = Vec::with_capacity(server.items.len());
|
||||||
|
for item in server.items {
|
||||||
|
if !server_by_id.contains_key(&item.id) {
|
||||||
|
server_order.push(item.id.clone());
|
||||||
|
}
|
||||||
|
server_by_id.insert(item.id.clone(), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items: Vec<MediaItem> = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// Local items first, in their original order. If the server also
|
||||||
|
// returned this item, take the server's copy (newer metadata).
|
||||||
|
for local in cache.items {
|
||||||
|
if !seen.insert(local.id.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match server_by_id.remove(&local.id) {
|
||||||
|
Some(server_item) => items.push(server_item),
|
||||||
|
None => items.push(local),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then append server-only items, preserving the server's order.
|
||||||
|
for id in server_order {
|
||||||
|
if let Some(server_item) = server_by_id.remove(&id) {
|
||||||
|
if seen.insert(id) {
|
||||||
|
items.push(server_item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_record_count = items.len();
|
||||||
|
SearchResult {
|
||||||
|
items,
|
||||||
|
total_record_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache-first query: try cache, fall back to server on miss.
|
||||||
|
///
|
||||||
|
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
||||||
|
/// 2. If cache has meaningful content → return immediately (fast path)
|
||||||
|
/// 3. If cache is empty/stale → query server (fresh data)
|
||||||
|
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||||
///
|
///
|
||||||
/// @req: UR-002 - Access media when online or offline
|
/// @req: UR-002 - Access media when online or offline
|
||||||
/// @req: DR-013 - Repository pattern for online/offline data access
|
/// @req: DR-013 - Repository pattern for online/offline data access
|
||||||
@@ -70,24 +151,20 @@ impl HybridRepository {
|
|||||||
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
{
|
{
|
||||||
// Wait for both to complete (cache has 100ms timeout)
|
// Try cache first (100ms timeout already applied by callers)
|
||||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
let cache_result = cache_future.await;
|
||||||
|
|
||||||
// Prefer cache if it has meaningful content
|
|
||||||
if let Ok(data) = &cache_result {
|
if let Ok(data) = &cache_result {
|
||||||
if data.has_content() {
|
if data.has_content() {
|
||||||
debug!("[HybridRepo] Using cache result (has content)");
|
debug!("[HybridRepo] Cache hit, returning immediately");
|
||||||
return Ok(data.clone());
|
return Ok(data.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to server result
|
// Cache miss — fall back to server
|
||||||
match server_result {
|
debug!("[HybridRepo] Cache miss, querying server");
|
||||||
Ok(data) => {
|
match server_future.await {
|
||||||
debug!("[HybridRepo] Using server result");
|
Ok(data) => Ok(data),
|
||||||
// TODO: Spawn background cache update
|
|
||||||
Ok(data)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Server failed, try to return cache even if empty
|
// Server failed, try to return cache even if empty
|
||||||
cache_result.or(Err(e))
|
cache_result.or(Err(e))
|
||||||
@@ -129,46 +206,57 @@ impl MediaRepository for HybridRepository {
|
|||||||
let parent_id_for_save = parent_id.clone();
|
let parent_id_for_save = parent_id.clone();
|
||||||
let opts_clone = options.clone();
|
let opts_clone = options.clone();
|
||||||
|
|
||||||
// Check cache first to see if we have data
|
// Start server request in background (non-blocking)
|
||||||
let cache_future = self.cache_with_timeout(async move {
|
let server_handle = tokio::spawn(async move {
|
||||||
offline.get_items(&parent_id, opts_clone).await
|
online.get_items(&parent_id_clone, options).await
|
||||||
});
|
});
|
||||||
|
|
||||||
let server_future = async move {
|
// Check cache first (fast, 100ms timeout)
|
||||||
online.get_items(&parent_id_clone, options).await
|
let cache_result = self.cache_with_timeout(async move {
|
||||||
};
|
offline.get_items(&parent_id, opts_clone).await
|
||||||
|
}).await;
|
||||||
|
|
||||||
// Wait for both, prefer cache if available
|
// Cache hit: return immediately, update cache in background
|
||||||
let (cache_result, server_result) = tokio::join!(cache_future, server_future);
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
// Check if cache had meaningful content
|
debug!("[HybridRepo] Cache hit for get_items, returning immediately for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
let cache_had_content = cache_result.as_ref()
|
// Background: save server result to cache when it arrives
|
||||||
.map(|data| data.has_content())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
// Prefer cache if it has content
|
|
||||||
let result = if cache_had_content {
|
|
||||||
debug!("[HybridRepo] Using cached data for parent {}", &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
|
||||||
cache_result?
|
|
||||||
} else {
|
|
||||||
// Use server result and save to cache for next time
|
|
||||||
let server_data = server_result?;
|
|
||||||
|
|
||||||
if !server_data.items.is_empty() {
|
|
||||||
let items_clone = server_data.items.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
match server_handle.await {
|
||||||
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
Ok(Ok(server_data)) if !server_data.items.is_empty() => {
|
||||||
} else {
|
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &server_data.items).await {
|
||||||
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
warn!("[HybridRepo] Background cache update failed: {:?}", e);
|
||||||
|
} else {
|
||||||
|
debug!("[HybridRepo] Background updated {} cached items for parent {}", server_data.items.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {} // Server failed or returned empty — keep existing cache
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return Ok(data.clone());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server_data
|
// Cache miss — wait for server result
|
||||||
};
|
match server_handle.await {
|
||||||
|
Ok(Ok(server_data)) => {
|
||||||
Ok(result)
|
if !server_data.items.is_empty() {
|
||||||
|
let items_clone = server_data.items.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = offline_for_save.save_to_cache(&parent_id_for_save, &items_clone).await {
|
||||||
|
warn!("[HybridRepo] Failed to save {} items to cache: {:?}", items_clone.len(), e);
|
||||||
|
} else {
|
||||||
|
debug!("[HybridRepo] Saved {} items to cache for parent {}", items_clone.len(), &parent_id_for_save[..8.min(parent_id_for_save.len())]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(server_data)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||||
|
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||||
|
message: format!("Server task failed: {}", join_err),
|
||||||
|
})),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
@@ -261,6 +349,27 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.parallel_race(cache_future, server_future).await
|
self.parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
let offline = Arc::clone(&self.offline);
|
||||||
|
let online = Arc::clone(&self.online);
|
||||||
|
let parent_id_owned = parent_id.map(|s| s.to_string());
|
||||||
|
let parent_id_clone = parent_id_owned.clone();
|
||||||
|
|
||||||
|
let cache_future = self.cache_with_timeout(async move {
|
||||||
|
offline.get_rediscover_albums(parent_id_owned.as_deref(), limit).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let server_future = async move {
|
||||||
|
online.get_rediscover_albums(parent_id_clone.as_deref(), limit).await
|
||||||
|
};
|
||||||
|
|
||||||
|
self.parallel_race(cache_future, server_future).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
let online = Arc::clone(&self.online);
|
||||||
@@ -408,6 +517,109 @@ impl MediaRepository for HybridRepository {
|
|||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
self.parallel_race(cache_future, server_future).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Playlist Methods =====
|
||||||
|
|
||||||
|
async fn create_playlist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.create_playlist(name, item_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.delete_playlist(playlist_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.rename_playlist(playlist_id, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_playlist_items(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
|
let offline = Arc::clone(&self.offline);
|
||||||
|
let offline_for_save = Arc::clone(&self.offline);
|
||||||
|
let online = Arc::clone(&self.online);
|
||||||
|
let playlist_id = playlist_id.to_string();
|
||||||
|
let playlist_id_clone = playlist_id.clone();
|
||||||
|
let playlist_id_for_save = playlist_id.clone();
|
||||||
|
|
||||||
|
// Start server request in background (non-blocking)
|
||||||
|
let server_handle = tokio::spawn(async move {
|
||||||
|
online.get_playlist_items(&playlist_id_clone).await
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check cache first (fast, 100ms timeout)
|
||||||
|
let cache_result = self.cache_with_timeout(async move {
|
||||||
|
offline.get_playlist_items(&playlist_id).await
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
// Cache hit: return immediately, update cache in background
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
|
debug!("[HybridRepo] Cache hit for playlist items, returning immediately");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(Ok(server_entries)) = server_handle.await {
|
||||||
|
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &server_entries).await {
|
||||||
|
warn!("[HybridRepo] Failed to update playlist cache: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cache_result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — wait for server result
|
||||||
|
match server_handle.await {
|
||||||
|
Ok(Ok(entries)) => {
|
||||||
|
let entries_clone = entries.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = offline_for_save.save_playlist_items_to_cache(&playlist_id_for_save, &entries_clone).await {
|
||||||
|
warn!("[HybridRepo] Failed to save playlist items to cache: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||||
|
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||||
|
message: format!("Server task failed: {}", join_err),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.add_to_playlist(playlist_id, item_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_from_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
entry_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.remove_from_playlist(playlist_id, entry_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_playlist_item(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
new_index: u32,
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
// Write operation - delegate directly to server
|
||||||
|
self.online.move_playlist_item(playlist_id, item_id, new_index).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -432,16 +644,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_query_count(&self) -> usize {
|
fn get_query_count(&self) -> usize {
|
||||||
*self.query_count.lock().unwrap()
|
*self.query_count.lock_safe()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_save_count(&self) -> usize {
|
fn get_save_count(&self) -> usize {
|
||||||
*self.save_count.lock().unwrap()
|
*self.save_count.lock_safe()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
|
||||||
*self.save_count.lock().unwrap() += 1;
|
*self.save_count.lock_safe() += 1;
|
||||||
*self.items.lock().unwrap() = items.to_vec();
|
*self.items.lock_safe() = items.to_vec();
|
||||||
Ok(items.len())
|
Ok(items.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,8 +665,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
*self.query_count.lock().unwrap() += 1;
|
*self.query_count.lock_safe() += 1;
|
||||||
let items = self.items.lock().unwrap().clone();
|
let items = self.items.lock_safe().clone();
|
||||||
let count = items.len();
|
let count = items.len();
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
items,
|
items,
|
||||||
@@ -486,6 +698,14 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -552,6 +772,38 @@ mod tests {
|
|||||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mock online repository that returns predefined items
|
/// Mock online repository that returns predefined items
|
||||||
@@ -569,7 +821,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_query_count(&self) -> usize {
|
fn get_query_count(&self) -> usize {
|
||||||
*self.query_count.lock().unwrap()
|
*self.query_count.lock_safe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +832,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
*self.query_count.lock().unwrap() += 1;
|
*self.query_count.lock_safe() += 1;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
items: self.items.clone(),
|
items: self.items.clone(),
|
||||||
total_record_count: self.items.len(),
|
total_record_count: self.items.len(),
|
||||||
@@ -611,6 +863,14 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, _parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -677,6 +937,38 @@ mod tests {
|
|||||||
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
async fn get_items_by_person(&self, _person_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_similar_items(&self, _item_id: &str, _limit: Option<usize>) -> Result<SearchResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_playlist(&self, _name: &str, _item_ids: &[String]) -> Result<PlaylistCreatedResult, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_playlist(&self, _playlist_id: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_playlist(&self, _playlist_id: &str, _name: &str) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_playlist_items(&self, _playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(&self, _playlist_id: &str, _item_ids: &[String]) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_from_playlist(&self, _playlist_id: &str, _entry_ids: &[String]) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_playlist_item(&self, _playlist_id: &str, _item_id: &str, _new_index: u32) -> Result<(), RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_test_item(id: &str, name: &str) -> MediaItem {
|
fn create_test_item(id: &str, name: &str) -> MediaItem {
|
||||||
@@ -878,4 +1170,75 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(result_with_items.has_content(), "Result with items should have content");
|
assert!(result_with_items.has_content(), "Result with items should have content");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_search_local_first_then_server_appended() {
|
||||||
|
let cache = SearchResult {
|
||||||
|
items: vec![
|
||||||
|
create_test_item("a", "Cached A"),
|
||||||
|
create_test_item("b", "Cached B"),
|
||||||
|
],
|
||||||
|
total_record_count: 2,
|
||||||
|
};
|
||||||
|
let server = SearchResult {
|
||||||
|
items: vec![
|
||||||
|
create_test_item("c", "Server C"),
|
||||||
|
create_test_item("d", "Server D"),
|
||||||
|
],
|
||||||
|
total_record_count: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = HybridRepository::merge_search_results(cache, server);
|
||||||
|
|
||||||
|
// Local items first (in order), then server-only items appended.
|
||||||
|
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a", "b", "c", "d"]);
|
||||||
|
assert_eq!(merged.total_record_count, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_search_dedupes_with_server_winning() {
|
||||||
|
// "b" appears in both. Server metadata should win, but the item keeps
|
||||||
|
// its earlier (local) position and is not duplicated.
|
||||||
|
let cache = SearchResult {
|
||||||
|
items: vec![
|
||||||
|
create_test_item("a", "Cached A"),
|
||||||
|
create_test_item("b", "Cached B"),
|
||||||
|
],
|
||||||
|
total_record_count: 2,
|
||||||
|
};
|
||||||
|
let server = SearchResult {
|
||||||
|
items: vec![
|
||||||
|
create_test_item("b", "Server B (fresher)"),
|
||||||
|
create_test_item("c", "Server C"),
|
||||||
|
],
|
||||||
|
total_record_count: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let merged = HybridRepository::merge_search_results(cache, server);
|
||||||
|
|
||||||
|
let ids: Vec<&str> = merged.items.iter().map(|i| i.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a", "b", "c"], "no duplicate, local position kept");
|
||||||
|
|
||||||
|
let b = merged.items.iter().find(|i| i.id == "b").unwrap();
|
||||||
|
assert_eq!(b.name, "Server B (fresher)", "server metadata wins on conflict");
|
||||||
|
assert_eq!(merged.total_record_count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_merge_search_handles_empty_sides() {
|
||||||
|
let only_server = HybridRepository::merge_search_results(
|
||||||
|
SearchResult { items: vec![], total_record_count: 0 },
|
||||||
|
SearchResult { items: vec![create_test_item("x", "X")], total_record_count: 1 },
|
||||||
|
);
|
||||||
|
assert_eq!(only_server.items.len(), 1);
|
||||||
|
assert_eq!(only_server.items[0].id, "x");
|
||||||
|
|
||||||
|
let only_cache = HybridRepository::merge_search_results(
|
||||||
|
SearchResult { items: vec![create_test_item("y", "Y")], total_record_count: 1 },
|
||||||
|
SearchResult { items: vec![], total_record_count: 0 },
|
||||||
|
);
|
||||||
|
assert_eq!(only_cache.items.len(), 1);
|
||||||
|
assert_eq!(only_cache.items[0].id, "y");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError>;
|
) -> Result<Vec<MediaItem>, RepoError>;
|
||||||
|
|
||||||
|
/// Get albums the user has played, but not recently ("rediscover" / haven't
|
||||||
|
/// listened to in a while). Returns albums sorted by least-recently played
|
||||||
|
/// first, optionally restricted to a parent library.
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError>;
|
||||||
|
|
||||||
/// Get resume movies
|
/// Get resume movies
|
||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError>;
|
||||||
|
|
||||||
@@ -147,6 +156,8 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Get subtitle URL (synchronous - just constructs URL)
|
/// Get subtitle URL (synchronous - just constructs URL)
|
||||||
|
/// Called by frontend via Tauri invoke (getSubtitleUrl in VideoPlayer.svelte)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn get_subtitle_url(
|
fn get_subtitle_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
@@ -156,6 +167,8 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Get video download URL (synchronous - just constructs URL)
|
/// Get video download URL (synchronous - just constructs URL)
|
||||||
|
/// Called by frontend via Tauri invoke (getVideoDownloadUrl in VideoDownloadButton.svelte)
|
||||||
|
#[allow(dead_code)]
|
||||||
fn get_video_download_url(
|
fn get_video_download_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
@@ -187,4 +200,68 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
item_id: &str,
|
item_id: &str,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<SearchResult, RepoError>;
|
) -> Result<SearchResult, RepoError>;
|
||||||
|
|
||||||
|
// ===== Playlist Methods =====
|
||||||
|
|
||||||
|
/// Create a new playlist on the server
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-019 - Get/create/update playlists
|
||||||
|
async fn create_playlist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<PlaylistCreatedResult, RepoError>;
|
||||||
|
|
||||||
|
/// Delete a playlist
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-019 - Get/create/update playlists
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Rename a playlist
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-019 - Get/create/update playlists
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Get playlist items with PlaylistItemId (needed for remove/reorder)
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-019 - Get/create/update playlists
|
||||||
|
async fn get_playlist_items(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||||
|
|
||||||
|
/// Add items to a playlist
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-020 - Add/remove items from playlist
|
||||||
|
async fn add_to_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Remove items from a playlist using entry IDs (PlaylistItemId, NOT media item IDs)
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-020 - Add/remove items from playlist
|
||||||
|
async fn remove_from_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
entry_ids: &[String],
|
||||||
|
) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
/// Move a playlist item to a new position
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
/// @req: JA-020 - Add/remove items from playlist
|
||||||
|
async fn move_playlist_item(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
new_index: u32,
|
||||||
|
) -> Result<(), RepoError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,26 @@ impl OfflineRepository {
|
|||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Temporarily disable foreign key constraints to avoid CASCADE DELETE issues
|
||||||
|
// when replacing stub parent items with their actual data
|
||||||
|
self.db_service.execute(Query::new("PRAGMA foreign_keys = OFF")).await
|
||||||
|
.map_err(|e| RepoError::Database { message: e })?;
|
||||||
|
|
||||||
|
// Ensure we re-enable foreign keys even if an error occurs
|
||||||
|
let result = self.save_to_cache_impl(parent_id, items, &now).await;
|
||||||
|
|
||||||
|
// Re-enable foreign key constraints
|
||||||
|
let _ = self.db_service.execute(Query::new("PRAGMA foreign_keys = ON")).await;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_to_cache_impl(
|
||||||
|
&self,
|
||||||
|
parent_id: &str,
|
||||||
|
items: &[MediaItem],
|
||||||
|
now: &str,
|
||||||
|
) -> Result<usize, RepoError> {
|
||||||
// Collect all unique parent IDs referenced by items being saved
|
// Collect all unique parent IDs referenced by items being saved
|
||||||
let mut parent_ids = std::collections::HashSet::new();
|
let mut parent_ids = std::collections::HashSet::new();
|
||||||
parent_ids.insert(parent_id.to_string());
|
parent_ids.insert(parent_id.to_string());
|
||||||
@@ -185,7 +205,7 @@ impl OfflineRepository {
|
|||||||
QueryParam::String(self.server_id.clone()),
|
QueryParam::String(self.server_id.clone()),
|
||||||
QueryParam::String("Parent".to_string()),
|
QueryParam::String("Parent".to_string()),
|
||||||
QueryParam::String("Folder".to_string()),
|
QueryParam::String("Folder".to_string()),
|
||||||
QueryParam::String(now.clone()),
|
QueryParam::String(now.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,8 +226,6 @@ impl OfflineRepository {
|
|||||||
let backdrop_tags_json = item.backdrop_image_tags.as_ref()
|
let backdrop_tags_json = item.backdrop_image_tags.as_ref()
|
||||||
.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
|
.map(|b| serde_json::to_string(b).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
|
||||||
|
|
||||||
// Use INSERT OR REPLACE to upsert items
|
// Use INSERT OR REPLACE to upsert items
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT OR REPLACE INTO items (
|
"INSERT OR REPLACE INTO items (
|
||||||
@@ -315,7 +333,7 @@ impl OfflineRepository {
|
|||||||
Some(r) => QueryParam::String(r.clone()),
|
Some(r) => QueryParam::String(r.clone()),
|
||||||
None => QueryParam::Null,
|
None => QueryParam::Null,
|
||||||
},
|
},
|
||||||
QueryParam::String(now.clone()),
|
QueryParam::String(now.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -328,6 +346,56 @@ impl OfflineRepository {
|
|||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cache playlist items from server into local database
|
||||||
|
/// Called by HybridRepository after fetching from online
|
||||||
|
pub async fn save_playlist_items_to_cache(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
entries: &[PlaylistEntry],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
let playlist_id = playlist_id.to_string();
|
||||||
|
let user_id = self.user_id.clone();
|
||||||
|
let entries: Vec<(String, String, usize)> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| (e.playlist_item_id.clone(), e.item.id.clone(), i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.transaction(move |tx| {
|
||||||
|
use crate::storage::db_service::{Query, QueryParam};
|
||||||
|
|
||||||
|
// Ensure playlist record exists
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"INSERT OR IGNORE INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, '', 0)",
|
||||||
|
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(user_id)],
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// Clear existing entries and re-insert
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"DELETE FROM playlist_items WHERE playlist_id = ?",
|
||||||
|
vec![QueryParam::String(playlist_id.clone())],
|
||||||
|
))?;
|
||||||
|
|
||||||
|
for (_, item_id, sort_order) in &entries {
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(playlist_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::Int(*sort_order as i32),
|
||||||
|
],
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to cache playlist items: {}", e),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -562,7 +630,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_items(&self, parent_id: Option<&str>, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_val = limit.unwrap_or(12);
|
let limit_val = limit.unwrap_or(12);
|
||||||
|
|
||||||
// Resume items are always playable items (Audio, Movie, Episode), so simple JOIN with downloads
|
// Resume items are video-only (Movie, Episode) - audio is handled by get_recently_played_audio
|
||||||
let (sql, params) = if let Some(pid) = parent_id {
|
let (sql, params) = if let Some(pid) = parent_id {
|
||||||
(
|
(
|
||||||
format!(
|
format!(
|
||||||
@@ -578,6 +646,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
|
WHERE i.server_id = ? AND ud.user_id = ? AND i.library_id = ?
|
||||||
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
||||||
AND d.status = 'completed'
|
AND d.status = 'completed'
|
||||||
|
AND i.item_type IN ('Movie', 'Episode')
|
||||||
ORDER BY ud.last_played_at DESC
|
ORDER BY ud.last_played_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
@@ -602,6 +671,7 @@ impl MediaRepository for OfflineRepository {
|
|||||||
WHERE i.server_id = ? AND ud.user_id = ?
|
WHERE i.server_id = ? AND ud.user_id = ?
|
||||||
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
AND ud.playback_position_ticks > 0 AND ud.is_played = 0
|
||||||
AND d.status = 'completed'
|
AND d.status = 'completed'
|
||||||
|
AND i.item_type IN ('Movie', 'Episode')
|
||||||
ORDER BY ud.last_played_at DESC
|
ORDER BY ud.last_played_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
@@ -708,6 +778,17 @@ impl MediaRepository for OfflineRepository {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
_parent_id: Option<&str>,
|
||||||
|
_limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
// "Rediscover" is a discovery feature over the full server library.
|
||||||
|
// Offline only holds downloaded items, so there is nothing meaningful
|
||||||
|
// to surface here; the hybrid repo serves this from the server instead.
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_val = limit.unwrap_or(12);
|
let limit_val = limit.unwrap_or(12);
|
||||||
|
|
||||||
@@ -749,41 +830,57 @@ impl MediaRepository for OfflineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
// Extract unique genres from cached items
|
// Derive genres from cached albums, tallying how many albums carry each
|
||||||
let mut genre_set = std::collections::HashSet::new();
|
// so the frontend can rank by popularity. We scope to MusicAlbum (genres
|
||||||
|
// power the music landing) and read every matching row — NOT DISTINCT —
|
||||||
|
// so the per-genre counts are real. Genres are stored as a JSON array
|
||||||
|
// string per item.
|
||||||
let (sql, params) = if let Some(pid) = parent_id {
|
let (sql, params) = if let Some(pid) = parent_id {
|
||||||
(
|
(
|
||||||
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND library_id = ? AND genres IS NOT NULL",
|
"SELECT genres FROM items WHERE server_id = ? AND library_id = ? \
|
||||||
|
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(self.server_id.clone()),
|
QueryParam::String(self.server_id.clone()),
|
||||||
QueryParam::String(pid.to_string()),
|
QueryParam::String(pid.to_string()),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
"SELECT DISTINCT genres FROM items WHERE server_id = ? AND genres IS NOT NULL",
|
"SELECT genres FROM items WHERE server_id = ? \
|
||||||
vec![QueryParam::String(self.server_id.clone())]
|
AND item_type = 'MusicAlbum' AND genres IS NOT NULL",
|
||||||
|
vec![QueryParam::String(self.server_id.clone())],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let query = Query::with_params(sql, params);
|
let query = Query::with_params(sql, params);
|
||||||
|
|
||||||
let genres_rows: Vec<String> = self.db_service.query_many(query, |row| row.get(0))
|
let genres_rows: Vec<String> = self
|
||||||
|
.db_service
|
||||||
|
.query_many(query, |row| row.get(0))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RepoError::Database { message: e })?;
|
.map_err(|e| RepoError::Database { message: e })?;
|
||||||
|
|
||||||
|
// genre name -> album count
|
||||||
|
let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||||
for genres_json in genres_rows {
|
for genres_json in genres_rows {
|
||||||
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
|
if let Ok(genres_vec) = serde_json::from_str::<Vec<String>>(&genres_json) {
|
||||||
|
// De-dupe within one album so a genre listed twice counts once.
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
for genre in genres_vec {
|
for genre in genres_vec {
|
||||||
genre_set.insert(genre);
|
if seen.insert(genre.clone()) {
|
||||||
|
*counts.entry(genre).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let genres = genre_set
|
let genres = counts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|name| Genre { id: name.clone(), name })
|
.map(|(name, count)| Genre {
|
||||||
|
id: name.clone(),
|
||||||
|
name,
|
||||||
|
album_count: Some(count),
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(genres)
|
Ok(genres)
|
||||||
@@ -1070,6 +1167,254 @@ impl MediaRepository for OfflineRepository {
|
|||||||
// Similar items require server-side computation and are not available offline
|
// Similar items require server-side computation and are not available offline
|
||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Playlist Methods =====
|
||||||
|
|
||||||
|
async fn create_playlist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||||
|
let playlist_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let user_id = self.user_id.clone();
|
||||||
|
let name = name.to_string();
|
||||||
|
let item_ids = item_ids.to_vec();
|
||||||
|
let pid = playlist_id.clone();
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.transaction(move |tx| {
|
||||||
|
use crate::storage::db_service::{Query, QueryParam};
|
||||||
|
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"INSERT INTO playlists (id, user_id, name, is_local) VALUES (?1, ?2, ?3, 1)",
|
||||||
|
vec![QueryParam::String(pid.clone()), QueryParam::String(user_id), QueryParam::String(name)],
|
||||||
|
))?;
|
||||||
|
|
||||||
|
for (i, item_id) in item_ids.iter().enumerate() {
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||||
|
vec![QueryParam::String(pid.clone()), QueryParam::String(item_id.clone()), QueryParam::Int(i as i32)],
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to create playlist: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(PlaylistCreatedResult { id: playlist_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"DELETE FROM playlists WHERE id = ?",
|
||||||
|
vec![QueryParam::String(playlist_id.to_string())],
|
||||||
|
);
|
||||||
|
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to delete playlist: {}", e),
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE playlists SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(name.to_string()),
|
||||||
|
QueryParam::String(playlist_id.to_string()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
self.db_service.execute(query).await.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to rename playlist: {}", e),
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_playlist_items(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT pi.id, \
|
||||||
|
i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres, \
|
||||||
|
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating, \
|
||||||
|
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists, \
|
||||||
|
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name, \
|
||||||
|
i.parent_index_number \
|
||||||
|
FROM playlist_items pi \
|
||||||
|
JOIN items i ON pi.item_id = i.id \
|
||||||
|
WHERE pi.playlist_id = ? \
|
||||||
|
ORDER BY pi.sort_order ASC",
|
||||||
|
vec![QueryParam::String(playlist_id.to_string())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let items = self.db_service
|
||||||
|
.query_many(query, |row| {
|
||||||
|
let entry_id: i64 = row.get(0)?;
|
||||||
|
// Columns offset by 1 because first column is pi.id
|
||||||
|
let cached = CachedItem {
|
||||||
|
id: row.get(1)?,
|
||||||
|
name: row.get(2)?,
|
||||||
|
item_type: row.get(3)?,
|
||||||
|
server_id: row.get(4)?,
|
||||||
|
parent_id: row.get(5)?,
|
||||||
|
library_id: row.get(6)?,
|
||||||
|
overview: row.get(7)?,
|
||||||
|
genres: row.get(8)?,
|
||||||
|
runtime_ticks: row.get(9)?,
|
||||||
|
production_year: row.get(10)?,
|
||||||
|
community_rating: row.get(11)?,
|
||||||
|
official_rating: row.get(12)?,
|
||||||
|
primary_image_tag: row.get(13)?,
|
||||||
|
backdrop_image_tags: None,
|
||||||
|
parent_backdrop_image_tags: None,
|
||||||
|
album_id: row.get(14)?,
|
||||||
|
album_name: row.get(15)?,
|
||||||
|
album_artist: row.get(16)?,
|
||||||
|
artists: row.get(17)?,
|
||||||
|
index_number: row.get(18)?,
|
||||||
|
series_id: row.get(19)?,
|
||||||
|
series_name: row.get(20)?,
|
||||||
|
season_id: row.get(21)?,
|
||||||
|
season_name: row.get(22)?,
|
||||||
|
parent_index_number: row.get(23)?,
|
||||||
|
};
|
||||||
|
Ok((entry_id.to_string(), cached))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to get playlist items: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(entry_id, cached)| PlaylistEntry {
|
||||||
|
playlist_item_id: entry_id,
|
||||||
|
item: Self::cached_item_to_media_item(cached, None),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
// Get current max sort_order
|
||||||
|
let max_query = Query::with_params(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), -1) FROM playlist_items WHERE playlist_id = ?",
|
||||||
|
vec![QueryParam::String(playlist_id.to_string())],
|
||||||
|
);
|
||||||
|
let max_order: i32 = self.db_service
|
||||||
|
.query_one(max_query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
let playlist_id = playlist_id.to_string();
|
||||||
|
let item_ids = item_ids.to_vec();
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.transaction(move |tx| {
|
||||||
|
use crate::storage::db_service::{Query, QueryParam};
|
||||||
|
|
||||||
|
for (i, item_id) in item_ids.iter().enumerate() {
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"INSERT OR IGNORE INTO playlist_items (playlist_id, item_id, sort_order) VALUES (?1, ?2, ?3)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(playlist_id.clone()),
|
||||||
|
QueryParam::String(item_id.clone()),
|
||||||
|
QueryParam::Int(max_order + 1 + i as i32),
|
||||||
|
],
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to add items to playlist: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_from_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
entry_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
let playlist_id = playlist_id.to_string();
|
||||||
|
let entry_ids = entry_ids.to_vec();
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.transaction(move |tx| {
|
||||||
|
use crate::storage::db_service::{Query, QueryParam};
|
||||||
|
|
||||||
|
for entry_id in &entry_ids {
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"DELETE FROM playlist_items WHERE playlist_id = ? AND id = ?",
|
||||||
|
vec![QueryParam::String(playlist_id.clone()), QueryParam::String(entry_id.clone())],
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to remove items from playlist: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_playlist_item(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
new_index: u32,
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
let playlist_id = playlist_id.to_string();
|
||||||
|
let item_id = item_id.to_string();
|
||||||
|
|
||||||
|
self.db_service
|
||||||
|
.transaction(move |tx| {
|
||||||
|
use crate::storage::db_service::{Query, QueryParam};
|
||||||
|
|
||||||
|
// Get all items ordered by sort_order
|
||||||
|
let items: Vec<(i64, String)> = tx.query_many(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT id, item_id FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order",
|
||||||
|
vec![QueryParam::String(playlist_id)],
|
||||||
|
),
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Find the item to move
|
||||||
|
let old_idx = items.iter().position(|(_, iid)| iid == &item_id);
|
||||||
|
if let Some(old_pos) = old_idx {
|
||||||
|
let mut ids = items;
|
||||||
|
let entry = ids.remove(old_pos);
|
||||||
|
let insert_at = (new_index as usize).min(ids.len());
|
||||||
|
ids.insert(insert_at, entry);
|
||||||
|
|
||||||
|
// Renumber all sort_orders
|
||||||
|
for (i, (entry_id, _)) in ids.iter().enumerate() {
|
||||||
|
tx.execute(Query::with_params(
|
||||||
|
"UPDATE playlist_items SET sort_order = ? WHERE id = ?",
|
||||||
|
vec![QueryParam::Int(i as i32), QueryParam::Int64(*entry_id)],
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepoError::Database {
|
||||||
|
message: format!("Failed to move playlist item: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1135,6 +1480,27 @@ mod tests {
|
|||||||
playback_context_id TEXT,
|
playback_context_id TEXT,
|
||||||
PRIMARY KEY (user_id, item_id)
|
PRIMARY KEY (user_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_local INTEGER DEFAULT 0,
|
||||||
|
jellyfin_id TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(playlist_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
||||||
"#).unwrap();
|
"#).unwrap();
|
||||||
|
|
||||||
// Insert a test server
|
// Insert a test server
|
||||||
@@ -1312,4 +1678,244 @@ mod tests {
|
|||||||
assert!(result.is_ok(), "Simple case should work: {:?}", result);
|
assert!(result.is_ok(), "Simple case should work: {:?}", result);
|
||||||
assert_eq!(result.unwrap(), 3);
|
assert_eq!(result.unwrap(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Playlist Tests =====
|
||||||
|
|
||||||
|
/// Helper to seed items into the DB for playlist tests
|
||||||
|
async fn seed_items(repo: &OfflineRepository, ids: &[&str]) {
|
||||||
|
let items: Vec<MediaItem> = ids.iter().map(|id| create_test_item(id, &format!("Track {}", id), Some("library-1"))).collect();
|
||||||
|
repo.save_to_cache("library-1", &items).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_create_empty() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
|
||||||
|
let result = repo.create_playlist("My Playlist", &[]).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let created = result.unwrap();
|
||||||
|
assert!(!created.id.is_empty(), "Should return a non-empty playlist ID");
|
||||||
|
|
||||||
|
// Verify playlist exists in DB
|
||||||
|
let name: String = db_service
|
||||||
|
.query_one(
|
||||||
|
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "My Playlist");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_create_with_items() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("With Tracks", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert_eq!(items[0].item.id, "t1");
|
||||||
|
assert_eq!(items[1].item.id, "t2");
|
||||||
|
assert_eq!(items[2].item.id, "t3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_delete() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["t1"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("To Delete", &["t1".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Delete it
|
||||||
|
repo.delete_playlist(&created.id).await.unwrap();
|
||||||
|
|
||||||
|
// Verify playlist is gone
|
||||||
|
let count: i32 = db_service
|
||||||
|
.query_one(
|
||||||
|
Query::with_params("SELECT COUNT(*) FROM playlists WHERE id = ?", vec![QueryParam::String(created.id.clone())]),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
|
||||||
|
// Verify cascade deleted playlist_items
|
||||||
|
let item_count: i32 = db_service
|
||||||
|
.query_one(
|
||||||
|
Query::with_params("SELECT COUNT(*) FROM playlist_items WHERE playlist_id = ?", vec![QueryParam::String(created.id)]),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(item_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_rename() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Original Name", &[]).await.unwrap();
|
||||||
|
repo.rename_playlist(&created.id, "New Name").await.unwrap();
|
||||||
|
|
||||||
|
let name: String = db_service
|
||||||
|
.query_one(
|
||||||
|
Query::with_params("SELECT name FROM playlists WHERE id = ?", vec![QueryParam::String(created.id)]),
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "New Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_get_items_preserves_order() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["a", "b", "c"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Ordered", &["c".into(), "a".into(), "b".into()]).await.unwrap();
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
// Order should match insertion order: c, a, b
|
||||||
|
assert_eq!(items[0].item.id, "c");
|
||||||
|
assert_eq!(items[1].item.id, "a");
|
||||||
|
assert_eq!(items[2].item.id, "b");
|
||||||
|
// Each entry should have a unique playlist_item_id
|
||||||
|
assert_ne!(items[0].playlist_item_id, items[1].playlist_item_id);
|
||||||
|
assert_ne!(items[1].playlist_item_id, items[2].playlist_item_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_get_items_empty_playlist() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Empty", &[]).await.unwrap();
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert!(items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_add_items() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Addable", &["t1".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Add two more tracks
|
||||||
|
repo.add_to_playlist(&created.id, &["t2".into(), "t3".into()]).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert_eq!(items[0].item.id, "t1");
|
||||||
|
assert_eq!(items[1].item.id, "t2");
|
||||||
|
assert_eq!(items[2].item.id, "t3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_add_duplicate_items_ignored() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["t1"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Dupes", &["t1".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Try to add the same item again
|
||||||
|
repo.add_to_playlist(&created.id, &["t1".into()]).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert_eq!(items.len(), 1, "Duplicate should be ignored (UNIQUE constraint)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_remove_items() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["t1", "t2", "t3"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Removable", &["t1".into(), "t2".into(), "t3".into()]).await.unwrap();
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
|
||||||
|
// Remove the middle track by its entry ID
|
||||||
|
let entry_id_to_remove = items[1].playlist_item_id.clone();
|
||||||
|
repo.remove_from_playlist(&created.id, &[entry_id_to_remove]).await.unwrap();
|
||||||
|
|
||||||
|
let items_after = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
assert_eq!(items_after.len(), 2);
|
||||||
|
assert_eq!(items_after[0].item.id, "t1");
|
||||||
|
assert_eq!(items_after[1].item.id, "t3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_move_item_forward() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Reorder", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Move 'a' (index 0) to index 2: expect b, c, a, d
|
||||||
|
repo.move_playlist_item(&created.id, "a", 2).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["b", "c", "a", "d"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_move_item_backward() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["a", "b", "c", "d"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("Reorder2", &["a".into(), "b".into(), "c".into(), "d".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Move 'd' (index 3) to index 0: expect d, a, b, c
|
||||||
|
repo.move_playlist_item(&created.id, "d", 0).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["d", "a", "b", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_move_item_to_end() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["a", "b", "c"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("MoveEnd", &["a".into(), "b".into(), "c".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Move 'a' to index 99 (beyond end, should clamp): expect b, c, a
|
||||||
|
repo.move_playlist_item(&created.id, "a", 99).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["b", "c", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_playlist_move_nonexistent_item_is_noop() {
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(db_service.clone(), "test-server".to_string(), "test-user".to_string());
|
||||||
|
seed_items(&repo, &["a", "b"]).await;
|
||||||
|
|
||||||
|
let created = repo.create_playlist("NoOp", &["a".into(), "b".into()]).await.unwrap();
|
||||||
|
|
||||||
|
// Move a nonexistent item - should not error, just no-op
|
||||||
|
repo.move_playlist_item(&created.id, "nonexistent", 0).await.unwrap();
|
||||||
|
|
||||||
|
let items = repo.get_playlist_items(&created.id).await.unwrap();
|
||||||
|
let ids: Vec<&str> = items.iter().map(|e| e.item.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a", "b"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info};
|
||||||
@@ -5,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::*};
|
||||||
|
|
||||||
@@ -14,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 {
|
||||||
@@ -28,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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +81,37 @@ impl OnlineRepository {
|
|||||||
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Download raw bytes from a URL using the shared authenticated HTTP client.
|
||||||
|
/// Used by thumbnail cache to download images with proper auth and connection reuse.
|
||||||
|
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let request = self.http_client.client.get(url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| format!("Download failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
|
||||||
|
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
response.bytes().await
|
||||||
|
.map(|b| b.to_vec())
|
||||||
|
.map_err(|e| format!("Failed to read bytes: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)
|
||||||
@@ -85,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)
|
||||||
@@ -120,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);
|
||||||
|
|
||||||
@@ -168,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,
|
||||||
@@ -185,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),
|
||||||
@@ -198,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 {
|
||||||
@@ -216,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
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,6 +331,31 @@ struct ItemsResponse {
|
|||||||
total_record_count: usize,
|
total_record_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jellyfin playlist creation response
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct CreatePlaylistResponse {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin playlist items response — items include PlaylistItemId
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct PlaylistItemsResponse {
|
||||||
|
items: Vec<JellyfinPlaylistItem>,
|
||||||
|
total_record_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct JellyfinPlaylistItem {
|
||||||
|
playlist_item_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
item: JellyfinItem,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct JellyfinItem {
|
struct JellyfinItem {
|
||||||
@@ -444,10 +568,22 @@ impl MediaRepository for OnlineRepository {
|
|||||||
if let Some(recursive) = opts.recursive {
|
if let Some(recursive) = opts.recursive {
|
||||||
endpoint.push_str(&format!("&Recursive={}", recursive));
|
endpoint.push_str(&format!("&Recursive={}", recursive));
|
||||||
}
|
}
|
||||||
|
if let Some(genres) = opts.genres {
|
||||||
|
if !genres.is_empty() {
|
||||||
|
// Genre names may contain spaces/ampersands, so percent-encode each.
|
||||||
|
let encoded: Vec<String> = genres
|
||||||
|
.iter()
|
||||||
|
.map(|g| urlencoding::encode(g).into_owned())
|
||||||
|
.collect();
|
||||||
|
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always request backdrop image fields
|
// Request image fields for list views (People only needed in get_item
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
|
// detail view). Genres is needed so cached items carry their genres,
|
||||||
|
// which lets the offline store derive genre lists + per-genre counts.
|
||||||
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
|
||||||
@@ -477,7 +613,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, parent_id, limit_str
|
self.user_id, parent_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -495,7 +631,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let mut endpoint = format!(
|
let mut endpoint = format!(
|
||||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video,Audio&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, limit_str
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -517,7 +653,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People", self.user_id, limit_str);
|
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
|
||||||
|
|
||||||
if let Some(sid) = series_id {
|
if let Some(sid) = series_id {
|
||||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||||
@@ -539,7 +675,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// Fetch more items to account for grouping reducing the count
|
// Fetch more items to account for grouping reducing the count
|
||||||
let fetch_limit = limit_val * 3;
|
let fetch_limit = limit_val * 3;
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, fetch_limit
|
self.user_id, fetch_limit
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -635,10 +771,36 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(final_result)
|
Ok(final_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_rediscover_albums(
|
||||||
|
&self,
|
||||||
|
parent_id: Option<&str>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
|
let limit_val = limit.unwrap_or(12);
|
||||||
|
// Ask Jellyfin for played albums sorted by least-recently played first.
|
||||||
|
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
||||||
|
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
||||||
|
let mut endpoint = format!(
|
||||||
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
|
self.user_id, limit_val
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(pid) = parent_id {
|
||||||
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
Ok(response
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let limit_str = limit.unwrap_or(16);
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, limit_str
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -651,7 +813,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
||||||
let mut endpoint = format!("/Genres?UserId={}", self.user_id);
|
// Ask Jellyfin to scope counts to albums and include them, so the
|
||||||
|
// frontend can rank genres by popularity without probing each one.
|
||||||
|
let mut endpoint = format!(
|
||||||
|
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
|
||||||
|
self.user_id
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(pid) = parent_id {
|
if let Some(pid) = parent_id {
|
||||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||||
@@ -668,17 +835,41 @@ impl MediaRepository for OnlineRepository {
|
|||||||
struct JellyfinGenre {
|
struct JellyfinGenre {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
|
// Which count field Jellyfin populates for a genre under
|
||||||
|
// Fields=ItemCounts varies by server/version: scoped queries may
|
||||||
|
// fill AlbumCount, others only ChildCount. Read whichever is
|
||||||
|
// present so ranking still works. Absent on servers that ignore
|
||||||
|
// Fields=ItemCounts entirely, so all stay optional.
|
||||||
|
album_count: Option<u32>,
|
||||||
|
child_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: GenresResponse = self.get_json(&endpoint).await?;
|
let response: GenresResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(response
|
let genres: Vec<Genre> = response
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|g| Genre {
|
.map(|g| Genre {
|
||||||
id: g.id,
|
id: g.id,
|
||||||
name: g.name,
|
name: g.name,
|
||||||
|
album_count: g.album_count.or(g.child_count),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect();
|
||||||
|
|
||||||
|
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
|
||||||
|
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
|
||||||
|
// see whether the server populates any count field. Remove once known.
|
||||||
|
log::warn!(
|
||||||
|
"get_genres: {} genres, {} carry counts. sample: {:?}",
|
||||||
|
genres.len(),
|
||||||
|
with_counts,
|
||||||
|
genres
|
||||||
|
.iter()
|
||||||
|
.take(8)
|
||||||
|
.map(|g| (g.name.as_str(), g.album_count))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(genres)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search(
|
async fn search(
|
||||||
@@ -687,19 +878,30 @@ impl MediaRepository for OnlineRepository {
|
|||||||
options: Option<SearchOptions>,
|
options: Option<SearchOptions>,
|
||||||
) -> Result<SearchResult, RepoError> {
|
) -> Result<SearchResult, RepoError> {
|
||||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
|
||||||
|
// SearchTerm is arbitrary user input and must be percent-encoded so that
|
||||||
|
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
|
||||||
|
// search like "Star Wars" would otherwise produce a malformed URL).
|
||||||
let mut endpoint = format!(
|
let mut endpoint = format!(
|
||||||
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
||||||
self.user_id, query, limit
|
self.user_id,
|
||||||
|
urlencoding::encode(query),
|
||||||
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
if let Some(types) = opts.include_item_types {
|
if let Some(types) = opts.include_item_types {
|
||||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
let encoded_types = types
|
||||||
|
.iter()
|
||||||
|
.map(|t| urlencoding::encode(t).into_owned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always request backdrop image fields
|
// Request image fields for list views (plus Genres so cached items
|
||||||
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,People");
|
// carry genres for offline genre lists/counts).
|
||||||
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres");
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
Ok(SearchResult {
|
Ok(SearchResult {
|
||||||
@@ -809,7 +1011,18 @@ impl MediaRepository for OnlineRepository {
|
|||||||
("h264,hevc".to_string(), "aac,mp3".to_string())
|
("h264,hevc".to_string(), "aac,mp3".to_string())
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
|
||||||
|
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
|
||||||
|
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
|
||||||
|
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
|
||||||
|
// profile is shared, so we keep the broadly-supported audio codecs.)
|
||||||
|
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||||
|
let (video_codecs, audio_codecs) = (
|
||||||
|
"h264".to_string(),
|
||||||
|
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
||||||
let (video_codecs, audio_codecs) = (
|
let (video_codecs, audio_codecs) = (
|
||||||
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
|
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
|
||||||
"aac,mp3,opus,vorbis,flac".to_string(),
|
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||||
@@ -1005,8 +1218,12 @@ impl MediaRepository for OnlineRepository {
|
|||||||
image_type.as_str()
|
image_type.as_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Authentication is handled by X-Emby-Authorization header in download_bytes()
|
||||||
|
// Do NOT include api_key here — some Jellyfin servers reject requests when
|
||||||
|
// api_key is present but the token doesn't match the expected format.
|
||||||
|
let mut params: Vec<String> = Vec::new();
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
let mut params = Vec::new();
|
|
||||||
if let Some(width) = opts.max_width {
|
if let Some(width) = opts.max_width {
|
||||||
params.push(format!("maxWidth={}", width));
|
params.push(format!("maxWidth={}", width));
|
||||||
}
|
}
|
||||||
@@ -1019,11 +1236,11 @@ impl MediaRepository for OnlineRepository {
|
|||||||
if let Some(tag) = opts.tag {
|
if let Some(tag) = opts.tag {
|
||||||
params.push(format!("tag={}", tag));
|
params.push(format!("tag={}", tag));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !params.is_empty() {
|
if !params.is_empty() {
|
||||||
url.push('?');
|
url.push('?');
|
||||||
url.push_str(¶ms.join("&"));
|
url.push_str(¶ms.join("&"));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
url
|
url
|
||||||
@@ -1082,23 +1299,29 @@ 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 request = self.http_client.client.delete(&url)
|
let result = async {
|
||||||
.header("X-Emby-Authorization", self.auth_header())
|
let request = self.http_client.client.delete(&url)
|
||||||
.build()
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
.map_err(|e| RepoError::Network {
|
.build()
|
||||||
message: format!("Failed to build request: {}", e),
|
.map_err(|e| RepoError::Network {
|
||||||
})?;
|
message: format!("Failed to build request: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
let response = self.http_client.request_with_retry(request).await
|
let response = self.http_client.request_with_retry(request).await
|
||||||
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(RepoError::Server {
|
return Err(RepoError::Server {
|
||||||
message: format!("HTTP {}", response.status()),
|
message: format!("HTTP {}", response.status()),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
Ok(())
|
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> {
|
||||||
@@ -1115,7 +1338,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
||||||
|
|
||||||
let mut endpoint = format!(
|
let mut endpoint = format!(
|
||||||
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
self.user_id, person_id, limit
|
self.user_id, person_id, limit
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1149,7 +1372,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
|
|
||||||
// Try the /Similar endpoint which works for most items
|
// Try the /Similar endpoint which works for most items
|
||||||
let endpoint = format!(
|
let endpoint = format!(
|
||||||
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,People",
|
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
||||||
item_id, self.user_id, limit_str
|
item_id, self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1163,6 +1386,146 @@ impl MediaRepository for OnlineRepository {
|
|||||||
total_record_count: response.total_record_count,
|
total_record_count: response.total_record_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Playlist Methods =====
|
||||||
|
|
||||||
|
async fn create_playlist(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<PlaylistCreatedResult, RepoError> {
|
||||||
|
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"Name": name,
|
||||||
|
"Ids": item_ids,
|
||||||
|
"MediaType": "Audio",
|
||||||
|
"UserId": self.user_id,
|
||||||
|
});
|
||||||
|
let response: CreatePlaylistResponse =
|
||||||
|
self.post_json_response("/Playlists", &body).await?;
|
||||||
|
Ok(PlaylistCreatedResult { id: response.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
||||||
|
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
||||||
|
let endpoint = format!("/Items/{}", playlist_id);
|
||||||
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
|
let request = self.http_client.client.delete(&url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| RepoError::Network {
|
||||||
|
message: format!("Failed to build request: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(RepoError::Server {
|
||||||
|
message: format!("HTTP {}", response.status()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
||||||
|
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
|
||||||
|
let endpoint = format!("/Items/{}", playlist_id);
|
||||||
|
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_playlist_items(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
) -> Result<Vec<PlaylistEntry>, RepoError> {
|
||||||
|
let endpoint = format!(
|
||||||
|
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
|
||||||
|
playlist_id, self.user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
||||||
|
debug!(
|
||||||
|
"[OnlineRepo] Got {} playlist items for {}",
|
||||||
|
response.items.len(),
|
||||||
|
playlist_id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(response
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.map(|pi| PlaylistEntry {
|
||||||
|
playlist_item_id: pi.playlist_item_id,
|
||||||
|
item: pi.item.to_media_item(self.user_id.clone()),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_to_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
info!(
|
||||||
|
"[OnlineRepo] Adding {} items to playlist {}",
|
||||||
|
item_ids.len(),
|
||||||
|
playlist_id
|
||||||
|
);
|
||||||
|
let ids_param = item_ids.join(",");
|
||||||
|
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
|
||||||
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_from_playlist(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
entry_ids: &[String],
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
info!(
|
||||||
|
"[OnlineRepo] Removing {} entries from playlist {}",
|
||||||
|
entry_ids.len(),
|
||||||
|
playlist_id
|
||||||
|
);
|
||||||
|
let ids_param = entry_ids.join(",");
|
||||||
|
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
|
||||||
|
let url = format!("{}{}", self.server_url, endpoint);
|
||||||
|
|
||||||
|
let request = self.http_client.client.delete(&url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header())
|
||||||
|
.build()
|
||||||
|
.map_err(|e| RepoError::Network {
|
||||||
|
message: format!("Failed to build request: {}", e),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| RepoError::Network { message: e.to_string() })?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(RepoError::Server {
|
||||||
|
message: format!("HTTP {}", response.status()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_playlist_item(
|
||||||
|
&self,
|
||||||
|
playlist_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
new_index: u32,
|
||||||
|
) -> Result<(), RepoError> {
|
||||||
|
info!(
|
||||||
|
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
||||||
|
item_id, playlist_id, new_index
|
||||||
|
);
|
||||||
|
let endpoint = format!(
|
||||||
|
"/Playlists/{}/Items/{}/Move/{}",
|
||||||
|
playlist_id, item_id, new_index
|
||||||
|
);
|
||||||
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1183,6 +1546,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();
|
||||||
@@ -1198,6 +1646,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();
|
||||||
@@ -1358,4 +1846,13 @@ mod tests {
|
|||||||
assert_eq!(response.items[0].id, "item1");
|
assert_eq!(response.items[0].id, "item1");
|
||||||
assert_eq!(response.items[1].id, "item2");
|
assert_eq!(response.items[1].id, "item2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_term_is_url_encoded() {
|
||||||
|
// A multi-word query (and one with a reserved character) must be
|
||||||
|
// percent-encoded before being placed in the SearchTerm query param,
|
||||||
|
// otherwise the request URL is malformed and search returns nothing.
|
||||||
|
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
|
||||||
|
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ mod tests {
|
|||||||
self.server_url, item_id, image_type
|
self.server_url, item_id, image_type
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut params = vec![("api_key", self.access_token.clone())];
|
// No api_key — image downloads use X-Emby-Authorization header
|
||||||
|
let mut params: Vec<(&str, String)> = Vec::new();
|
||||||
|
|
||||||
if let Some(opts) = options {
|
if let Some(opts) = options {
|
||||||
if let Some(max_width) = opts.max_width {
|
if let Some(max_width) = opts.max_width {
|
||||||
@@ -304,14 +305,11 @@ mod tests {
|
|||||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
||||||
let download_url = repo.get_video_download_url("item123", "720p");
|
let download_url = repo.get_video_download_url("item123", "720p");
|
||||||
|
|
||||||
// These URLs are constructed in BACKEND and returned to frontend
|
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
|
||||||
// Frontend never receives this token directly
|
assert!(!image_url.contains("api_key="));
|
||||||
assert!(image_url.contains("api_key=super_secret_token"));
|
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
|
||||||
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
assert!(subtitle_url.contains("api_key=super_secret_token"));
|
||||||
assert!(download_url.contains("api_key=super_secret_token"));
|
assert!(download_url.contains("api_key=super_secret_token"));
|
||||||
|
|
||||||
// In actual implementation, frontend would only get the URL string
|
|
||||||
// Frontend cannot construct its own URLs or extract the token
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -335,10 +333,10 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", None);
|
let url = repo.get_image_url("id123", "Primary", None);
|
||||||
|
|
||||||
// Should be valid format
|
// Should be valid format (no api_key — auth via header)
|
||||||
assert!(url.starts_with("https://server.com"));
|
assert!(url.starts_with("https://server.com"));
|
||||||
assert!(url.contains("/Items/id123/Images/Primary"));
|
assert!(url.contains("/Items/id123/Images/Primary"));
|
||||||
assert!(url.contains("?api_key="));
|
assert!(!url.contains("api_key="));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -353,13 +351,13 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
||||||
|
|
||||||
// Should have single ? separator
|
// Should have single ? separator with params
|
||||||
let question_marks = url.matches('?').count();
|
let question_marks = url.matches('?').count();
|
||||||
assert_eq!(question_marks, 1);
|
assert_eq!(question_marks, 1);
|
||||||
|
|
||||||
// Should have ampersands between params
|
// Should have params for maxWidth and maxHeight
|
||||||
assert!(url.contains("?"));
|
assert!(url.contains("maxWidth=300"));
|
||||||
assert!(url.contains("&"));
|
assert!(url.contains("maxHeight=200"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -368,8 +366,7 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
||||||
|
|
||||||
// Should handle special characters in token and id
|
// Should handle special characters in id (no token in URL anymore)
|
||||||
assert!(url.contains("token_with_special-chars"));
|
|
||||||
assert!(url.contains("item-with-special_chars"));
|
assert!(url.contains("item-with-special_chars"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,9 +380,9 @@ mod tests {
|
|||||||
// Backend generates full URL with credentials
|
// Backend generates full URL with credentials
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
let url = repo.get_image_url("item123", "Primary", None);
|
||||||
|
|
||||||
// URL is complete and ready to use
|
// URL is complete and ready to use (auth via header, not api_key)
|
||||||
assert!(url.starts_with("https://"));
|
assert!(url.starts_with("https://"));
|
||||||
assert!(url.contains("api_key="));
|
assert!(url.contains("/Items/item123/Images/Primary"));
|
||||||
|
|
||||||
// Frontend never constructs URLs directly
|
// Frontend never constructs URLs directly
|
||||||
// Frontend only receives pre-constructed URLs from backend
|
// Frontend only receives pre-constructed URLs from backend
|
||||||
@@ -408,7 +405,6 @@ mod tests {
|
|||||||
assert!(url.contains("maxHeight=200"));
|
assert!(url.contains("maxHeight=200"));
|
||||||
assert!(url.contains("quality=90"));
|
assert!(url.contains("quality=90"));
|
||||||
assert!(url.contains("tag=abc"));
|
assert!(url.contains("tag=abc"));
|
||||||
assert!(url.contains("api_key=token"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -423,8 +419,8 @@ mod tests {
|
|||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
||||||
|
|
||||||
// Should only have api_key
|
// Should have no query params (no api_key, no options)
|
||||||
assert!(url.contains("api_key=token"));
|
assert!(!url.contains("?"));
|
||||||
assert!(!url.contains("maxWidth"));
|
assert!(!url.contains("maxWidth"));
|
||||||
assert!(!url.contains("maxHeight"));
|
assert!(!url.contains("maxHeight"));
|
||||||
assert!(!url.contains("quality"));
|
assert!(!url.contains("quality"));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Error types for repository operations
|
/// Error types for repository operations
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum RepoError {
|
pub enum RepoError {
|
||||||
Network { message: String },
|
Network { message: String },
|
||||||
@@ -28,7 +28,7 @@ impl std::fmt::Display for RepoError {
|
|||||||
impl std::error::Error for RepoError {}
|
impl std::error::Error for RepoError {}
|
||||||
|
|
||||||
/// Library (media collection)
|
/// Library (media collection)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -39,7 +39,7 @@ pub struct Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User-specific data for an item (playback state, favorites, etc.)
|
/// User-specific data for an item (playback state, favorites, etc.)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserData {
|
pub struct UserData {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -59,15 +59,17 @@ pub struct UserData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Artist item with ID and name (for clickable artist links)
|
/// Artist item with ID and name (for clickable artist links)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ArtistItem {
|
pub struct ArtistItem {
|
||||||
|
#[serde(alias = "Id")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
#[serde(alias = "Name")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Person (cast/crew member) - for movies, series, and episodes
|
/// Person (cast/crew member) - for movies, series, and episodes
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
/// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
|
/// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
|
||||||
@@ -95,7 +97,7 @@ pub struct Person {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media item
|
/// Media item
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -118,6 +120,7 @@ pub struct MediaItem {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub official_rating: Option<String>,
|
pub official_rating: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "runTimeTicks")]
|
||||||
pub runtime_ticks: Option<i64>,
|
pub runtime_ticks: Option<i64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub primary_image_tag: Option<String>,
|
pub primary_image_tag: Option<String>,
|
||||||
@@ -158,7 +161,7 @@ pub struct MediaItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media stream information (audio, video, subtitle tracks)
|
/// Media stream information (audio, video, subtitle tracks)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MediaStream {
|
pub struct MediaStream {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -175,7 +178,7 @@ pub struct MediaStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Media source information
|
/// Media source information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MediaSource {
|
pub struct MediaSource {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -194,7 +197,7 @@ pub struct MediaSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Search result with pagination
|
/// Search result with pagination
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SearchResult {
|
pub struct SearchResult {
|
||||||
pub items: Vec<MediaItem>,
|
pub items: Vec<MediaItem>,
|
||||||
@@ -202,7 +205,7 @@ pub struct SearchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Options for querying items
|
/// Options for querying items
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GetItemsOptions {
|
pub struct GetItemsOptions {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -224,7 +227,7 @@ pub struct GetItemsOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Options for search queries
|
/// Options for search queries
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SearchOptions {
|
pub struct SearchOptions {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -236,7 +239,7 @@ pub struct SearchOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Playback information
|
/// Playback information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaybackInfo {
|
pub struct PlaybackInfo {
|
||||||
pub media_source_id: String,
|
pub media_source_id: String,
|
||||||
@@ -247,15 +250,19 @@ pub struct PlaybackInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Genre
|
/// Genre
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Genre {
|
pub struct Genre {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Number of albums tagged with this genre, when the backend can supply it
|
||||||
|
/// (online only). Lets the frontend rank/pick genres without probing each
|
||||||
|
/// one. `None` when unknown (e.g. offline).
|
||||||
|
pub album_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Image type
|
/// Image type
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ImageType {
|
pub enum ImageType {
|
||||||
Primary,
|
Primary,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
@@ -277,7 +284,7 @@ impl ImageType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Image options
|
/// Image options
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ImageOptions {
|
pub struct ImageOptions {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -331,6 +338,41 @@ impl MeaningfulContent for PlaybackInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
|
||||||
|
/// needed for remove/reorder operations (distinct from the media item's ID)
|
||||||
|
///
|
||||||
|
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistEntry {
|
||||||
|
/// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||||
|
pub playlist_item_id: String,
|
||||||
|
/// The underlying media item
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub item: MediaItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of creating a playlist
|
||||||
|
///
|
||||||
|
/// @req: JA-019 - Get/create/update playlists
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistCreatedResult {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeaningfulContent for Vec<PlaylistEntry> {
|
||||||
|
fn has_content(&self) -> bool {
|
||||||
|
!self.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeaningfulContent for PlaylistCreatedResult {
|
||||||
|
fn has_content(&self) -> bool {
|
||||||
|
!self.id.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -367,15 +409,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_artist_item_serialize() {
|
fn test_artist_item_serialize() {
|
||||||
// Test that ArtistItem serializes to PascalCase for consistency
|
// ArtistItem serializes to camelCase for the frontend; it still accepts
|
||||||
|
// Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
|
||||||
let artist = ArtistItem {
|
let artist = ArtistItem {
|
||||||
id: "test-id".to_string(),
|
id: "test-id".to_string(),
|
||||||
name: "Test Artist".to_string(),
|
name: "Test Artist".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
let json = serde_json::to_string(&artist).expect("Failed to serialize");
|
||||||
assert!(json.contains(r#""Id":"test-id""#));
|
assert!(json.contains(r#""id":"test-id""#));
|
||||||
assert!(json.contains(r#""Name":"Test Artist""#));
|
assert!(json.contains(r#""name":"Test Artist""#));
|
||||||
|
|
||||||
|
let from_pascal: ArtistItem =
|
||||||
|
serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
|
||||||
|
assert_eq!(from_pascal.id, "x");
|
||||||
|
assert_eq!(from_pascal.name, "Y");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -550,7 +598,7 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let item = result.unwrap();
|
let item = result.unwrap();
|
||||||
let people = item.people.expect("Expected people array");
|
let people = item.people.as_ref().expect("Expected people array");
|
||||||
assert_eq!(people.len(), 2);
|
assert_eq!(people.len(), 2);
|
||||||
assert_eq!(people[0].name, "John Doe");
|
assert_eq!(people[0].name, "John Doe");
|
||||||
assert_eq!(people[0].person_type, "Actor");
|
assert_eq!(people[0].person_type, "Actor");
|
||||||
@@ -563,4 +611,103 @@ mod tests {
|
|||||||
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
assert!(people_array[0].get("type").is_some(), "Serialized person should have 'type' field");
|
||||||
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playlist_entry_serialization() {
|
||||||
|
let entry = PlaylistEntry {
|
||||||
|
playlist_item_id: "entry-abc-123".to_string(),
|
||||||
|
item: MediaItem {
|
||||||
|
id: "track1".to_string(),
|
||||||
|
name: "Test Track".to_string(),
|
||||||
|
item_type: "Audio".to_string(),
|
||||||
|
server_id: "server1".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
library_id: None,
|
||||||
|
overview: None,
|
||||||
|
genres: None,
|
||||||
|
production_year: None,
|
||||||
|
community_rating: None,
|
||||||
|
official_rating: None,
|
||||||
|
runtime_ticks: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
backdrop_image_tags: None,
|
||||||
|
parent_backdrop_image_tags: None,
|
||||||
|
album_id: None,
|
||||||
|
album_name: None,
|
||||||
|
album_artist: None,
|
||||||
|
artists: Some(vec!["Artist One".to_string()]),
|
||||||
|
artist_items: None,
|
||||||
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
series_id: None,
|
||||||
|
series_name: None,
|
||||||
|
season_id: None,
|
||||||
|
season_name: None,
|
||||||
|
user_data: None,
|
||||||
|
media_streams: None,
|
||||||
|
media_sources: None,
|
||||||
|
people: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&entry).expect("Failed to serialize");
|
||||||
|
// playlistItemId is camelCase
|
||||||
|
assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
|
||||||
|
// Flattened MediaItem fields appear at top level
|
||||||
|
assert!(json.contains(r#""id":"track1""#));
|
||||||
|
assert!(json.contains(r#""name":"Test Track""#));
|
||||||
|
assert!(json.contains(r#""type":"Audio""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playlist_created_result_serialization() {
|
||||||
|
let result = PlaylistCreatedResult {
|
||||||
|
id: "playlist-new-123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&result).expect("Failed to serialize");
|
||||||
|
assert!(json.contains(r#""id":"playlist-new-123""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playlist_entry_meaningful_content() {
|
||||||
|
let empty: Vec<PlaylistEntry> = vec![];
|
||||||
|
assert!(!empty.has_content());
|
||||||
|
|
||||||
|
let non_empty = vec![PlaylistEntry {
|
||||||
|
playlist_item_id: "e1".to_string(),
|
||||||
|
item: MediaItem {
|
||||||
|
id: "1".to_string(),
|
||||||
|
name: "Track".to_string(),
|
||||||
|
item_type: "Audio".to_string(),
|
||||||
|
server_id: "s1".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
library_id: None,
|
||||||
|
overview: None,
|
||||||
|
genres: None,
|
||||||
|
production_year: None,
|
||||||
|
community_rating: None,
|
||||||
|
official_rating: None,
|
||||||
|
runtime_ticks: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
backdrop_image_tags: None,
|
||||||
|
parent_backdrop_image_tags: None,
|
||||||
|
album_id: None,
|
||||||
|
album_name: None,
|
||||||
|
album_artist: None,
|
||||||
|
artists: None,
|
||||||
|
artist_items: None,
|
||||||
|
index_number: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
series_id: None,
|
||||||
|
series_name: None,
|
||||||
|
season_id: None,
|
||||||
|
season_name: None,
|
||||||
|
user_data: None,
|
||||||
|
media_streams: None,
|
||||||
|
media_sources: None,
|
||||||
|
people: None,
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
assert!(non_empty.has_content());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user