Compare commits
No commits in common. "e3797f32ca58f4ed5ab4cbc0972515f52072deb0" and "e560543181c6e0bafbd0b7d2fee471d2a94f90dd" have entirely different histories.
e3797f32ca
...
e560543181
@ -1,18 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.claude
|
|
||||||
.svelte-kit
|
|
||||||
build
|
|
||||||
dist
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
target
|
|
||||||
*.apk
|
|
||||||
*.aab
|
|
||||||
*.log
|
|
||||||
coverage
|
|
||||||
src-tauri/gen
|
|
||||||
src-tauri/target
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
name: '🏗️ Build and Test JellyTau'
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths-ignore:
|
|
||||||
- '**/*.md'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build APK and Run Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Cache Rust dependencies
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/registry
|
|
||||||
~/.cargo/git
|
|
||||||
src-tauri/target
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- 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: Run frontend tests
|
|
||||||
run: bun test
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: |
|
|
||||||
cd src-tauri
|
|
||||||
cargo test
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Build Android APK
|
|
||||||
id: build
|
|
||||||
run: |
|
|
||||||
mkdir -p artifacts
|
|
||||||
bun run tauri android build --apk true
|
|
||||||
|
|
||||||
# Find the generated APK file
|
|
||||||
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
|
||||||
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Found artifact: ${ARTIFACT}"
|
|
||||||
|
|
||||||
- name: Upload build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-apk
|
|
||||||
path: ${{ steps.build.outputs.artifact }}
|
|
||||||
retention-days: 30
|
|
||||||
if-no-files-found: error
|
|
||||||
@ -1,337 +0,0 @@
|
|||||||
name: Build & Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Version to build (e.g., v1.0.0)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Run Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
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
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Run frontend tests
|
|
||||||
run: bun run test --run
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Run Rust tests
|
|
||||||
run: bun run test:rust
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Check TypeScript
|
|
||||||
run: bun run check
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
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
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Build for Linux
|
|
||||||
run: bun run tauri build
|
|
||||||
env:
|
|
||||||
TAURI_SKIP_UPDATER: true
|
|
||||||
|
|
||||||
- name: Prepare Linux artifacts
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/linux
|
|
||||||
# Copy AppImage
|
|
||||||
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
|
|
||||||
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
|
|
||||||
fi
|
|
||||||
# Copy .deb if built
|
|
||||||
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
|
|
||||||
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
|
|
||||||
fi
|
|
||||||
ls -lah dist/linux/
|
|
||||||
|
|
||||||
- name: Upload Linux build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: dist/linux/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
build-android:
|
|
||||||
name: Build Android
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
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
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cargo/bin/
|
|
||||||
~/.cargo/registry/index/
|
|
||||||
~/.cargo/registry/cache/
|
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-cargo-android-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Build for Android
|
|
||||||
run: bun run tauri android build
|
|
||||||
env:
|
|
||||||
ANDROID_NDK_HOME: ${{ android.ndk-home }}
|
|
||||||
ANDROID_SDK_ROOT: ${{ android.sdk-root }}
|
|
||||||
ANDROID_HOME: ${{ android.sdk-root }}
|
|
||||||
|
|
||||||
- name: Prepare Android artifacts
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/android
|
|
||||||
# Copy APK
|
|
||||||
if [ -f "src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk" ]; then
|
|
||||||
cp src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk dist/android/jellytau-release.apk
|
|
||||||
fi
|
|
||||||
# Copy AAB (Android App Bundle) if built
|
|
||||||
if [ -f "src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" ]; then
|
|
||||||
cp src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab dist/android/jellytau-release.aab
|
|
||||||
fi
|
|
||||||
ls -lah dist/android/
|
|
||||||
|
|
||||||
- name: Upload Android build artifact
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: dist/android/
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
name: Create Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-linux, build-android]
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: tag_name
|
|
||||||
run: |
|
|
||||||
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
echo "RELEASE_NAME=JellyTau ${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-linux
|
|
||||||
path: artifacts/linux/
|
|
||||||
|
|
||||||
- name: Download Android artifacts
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: jellytau-android
|
|
||||||
path: artifacts/android/
|
|
||||||
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
echo "## 📱 JellyTau $VERSION Release" > release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### 📦 Downloads" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux" >> 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 "" >> release_notes.md
|
|
||||||
echo "#### Android" >> release_notes.md
|
|
||||||
echo "- **APK** - Install via `adb install jellytau-release.apk` or sideload via file manager" >> release_notes.md
|
|
||||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### ✨ What's New" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### 🔧 Installation" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux (AppImage)" >> release_notes.md
|
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
|
||||||
echo "chmod +x jellytau_*.AppImage" >> release_notes.md
|
|
||||||
echo "./jellytau_*.AppImage" >> release_notes.md
|
|
||||||
echo "\`\`\`" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Linux (DEB)" >> release_notes.md
|
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
|
||||||
echo "sudo dpkg -i jellytau_*.deb" >> release_notes.md
|
|
||||||
echo "jellytau" >> release_notes.md
|
|
||||||
echo "\`\`\`" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "#### Android" >> 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 "" >> release_notes.md
|
|
||||||
echo "### 🐛 Known Issues" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "### 📝 Requirements" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "**Linux:**" >> release_notes.md
|
|
||||||
echo "- 64-bit Linux system" >> release_notes.md
|
|
||||||
echo "- GLIBC 2.29+" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "**Android:**" >> release_notes.md
|
|
||||||
echo "- Android 8.0 or higher" >> release_notes.md
|
|
||||||
echo "- 50MB free storage" >> release_notes.md
|
|
||||||
echo "" >> release_notes.md
|
|
||||||
echo "---" >> release_notes.md
|
|
||||||
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
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:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Upload to Gitea Releases
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
|
|
||||||
echo "📦 Release artifacts prepared for $VERSION"
|
|
||||||
echo ""
|
|
||||||
echo "Linux:"
|
|
||||||
ls -lh artifacts/linux/ || echo "No Linux artifacts"
|
|
||||||
echo ""
|
|
||||||
echo "Android:"
|
|
||||||
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
|
|
||||||
run: |
|
|
||||||
echo "## 🎉 Release Published"
|
|
||||||
echo ""
|
|
||||||
echo "**Version:** ${{ steps.tag_name.outputs.VERSION }}"
|
|
||||||
echo "**Tag:** ${{ github.ref }}"
|
|
||||||
echo ""
|
|
||||||
echo "Artifacts:"
|
|
||||||
echo "- Linux artifacts in: artifacts/linux/"
|
|
||||||
echo "- Android artifacts in: artifacts/android/"
|
|
||||||
echo ""
|
|
||||||
echo "Visit the Release page to download files."
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
name: Traceability Validation
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
validate-traces:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Check Requirement Traces
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Extract traces
|
|
||||||
run: |
|
|
||||||
echo "🔍 Extracting requirement traces..."
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
|
|
||||||
- name: Validate traces
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "📊 Validating requirement traceability..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Parse JSON
|
|
||||||
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
|
||||||
UR=$(jq '.byType.UR | length' traces-report.json)
|
|
||||||
IR=$(jq '.byType.IR | length' traces-report.json)
|
|
||||||
DR=$(jq '.byType.DR | length' traces-report.json)
|
|
||||||
JA=$(jq '.byType.JA | length' traces-report.json)
|
|
||||||
|
|
||||||
# Print coverage report
|
|
||||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Coverage Summary:"
|
|
||||||
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
|
|
||||||
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
|
|
||||||
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
|
|
||||||
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
COVERED=$((UR + IR + DR + JA))
|
|
||||||
TOTAL_REQS=114
|
|
||||||
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
|
|
||||||
|
|
||||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check minimum threshold
|
|
||||||
MIN_THRESHOLD=50
|
|
||||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
|
||||||
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
|
|
||||||
|
|
||||||
- name: Check modified files
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
echo "🔍 Checking modified files for traces..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Get changed files
|
|
||||||
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || echo "")
|
|
||||||
|
|
||||||
if [ -z "$CHANGED" ]; then
|
|
||||||
echo "✅ No TypeScript/Rust files changed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📝 Changed files:"
|
|
||||||
echo "$CHANGED" | sed 's/^/ /'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check each file
|
|
||||||
MISSING_TRACES=0
|
|
||||||
while IFS= read -r file; do
|
|
||||||
# Skip test files
|
|
||||||
if [[ "$file" == *".test."* ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
if ! grep -q "TRACES:" "$file"; then
|
|
||||||
echo "⚠️ Missing TRACES: $file"
|
|
||||||
MISSING_TRACES=$((MISSING_TRACES + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$CHANGED"
|
|
||||||
|
|
||||||
if [ "$MISSING_TRACES" -gt 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "📝 Recommendation: Add TRACES comments to new/modified code"
|
|
||||||
echo " Format: // TRACES: UR-001, UR-002 | DR-003"
|
|
||||||
echo ""
|
|
||||||
echo "💡 For more info, see: scripts/README.md"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate full report
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "📄 Generating full traceability report..."
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Display report summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo ""
|
|
||||||
echo "📊 Full Report Generated"
|
|
||||||
echo "📁 Location: docs/TRACEABILITY.md"
|
|
||||||
echo ""
|
|
||||||
head -50 docs/TRACEABILITY.md || true
|
|
||||||
|
|
||||||
- name: Save artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: traceability-reports
|
|
||||||
path: |
|
|
||||||
traces-report.json
|
|
||||||
docs/TRACEABILITY.md
|
|
||||||
retention-days: 30
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
name: Requirement Traceability Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
traceability:
|
|
||||||
name: Validate Requirement Traces
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: bun install
|
|
||||||
|
|
||||||
- name: Extract requirement traces
|
|
||||||
run: bun run traces:json > traces.json
|
|
||||||
|
|
||||||
- name: Validate trace format
|
|
||||||
run: |
|
|
||||||
if ! jq empty traces.json 2>/dev/null; then
|
|
||||||
echo "❌ Invalid traces.json format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ Traces JSON is valid"
|
|
||||||
|
|
||||||
- name: Check requirement coverage
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Extract coverage stats
|
|
||||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
|
||||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
|
||||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
|
||||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
|
||||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
|
||||||
|
|
||||||
echo "## 📊 Requirement Traceability Report"
|
|
||||||
echo ""
|
|
||||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
|
||||||
echo ""
|
|
||||||
echo "### Requirements Covered:"
|
|
||||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
|
||||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
|
||||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
|
||||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Set minimum coverage threshold (50%)
|
|
||||||
TOTAL_REQS=114
|
|
||||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
|
||||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
|
||||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
|
||||||
|
|
||||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
|
||||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check for new untraced code
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Find files modified in this PR/push
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
|
||||||
else
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
|
||||||
echo "✅ No source files changed"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "### Files Changed:"
|
|
||||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if changed files have TRACES
|
|
||||||
UNTRACED_FILES=""
|
|
||||||
while IFS= read -r file; do
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
# Skip test files and generated code
|
|
||||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if file has TRACES comments
|
|
||||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
|
||||||
UNTRACED_FILES+="$file"$'\n'
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$CHANGED_FILES"
|
|
||||||
|
|
||||||
if [ -n "$UNTRACED_FILES" ]; then
|
|
||||||
echo "⚠️ New files without TRACES:"
|
|
||||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
|
||||||
echo ""
|
|
||||||
echo "💡 Add TRACES comments to link code to requirements:"
|
|
||||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
|
||||||
else
|
|
||||||
echo "✅ All changed files have TRACES comments"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate traceability report
|
|
||||||
if: always()
|
|
||||||
run: bun run traces:markdown
|
|
||||||
|
|
||||||
- name: Upload traceability report
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: traceability-report
|
|
||||||
path: docs/TRACEABILITY.md
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Comment PR with coverage report
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
|
||||||
|
|
||||||
const urCount = traces.byType.UR.length;
|
|
||||||
const irCount = traces.byType.IR.length;
|
|
||||||
const drCount = traces.byType.DR.length;
|
|
||||||
const jaCount = traces.byType.JA.length;
|
|
||||||
const total = urCount + irCount + drCount + jaCount;
|
|
||||||
const coverage = Math.round((total / 114) * 100);
|
|
||||||
|
|
||||||
const comment = `## 📊 Requirement Traceability Report
|
|
||||||
|
|
||||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
|
||||||
|
|
||||||
### By Type:
|
|
||||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
|
||||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
|
||||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
|
||||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
|
||||||
|
|
||||||
**Total Traces:** ${traces.totalTraces}
|
|
||||||
|
|
||||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: comment
|
|
||||||
});
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
# Building and Pushing the JellyTau Builder Image
|
|
||||||
|
|
||||||
This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker installed and running
|
|
||||||
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
|
|
||||||
- Docker registry credentials configured (`docker login`)
|
|
||||||
|
|
||||||
## Building the Builder Image
|
|
||||||
|
|
||||||
### Step 1: Build the Image Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From the project root
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a local image with:
|
|
||||||
- All system dependencies
|
|
||||||
- Rust with Android targets
|
|
||||||
- Android SDK and NDK
|
|
||||||
- Node.js and Bun
|
|
||||||
- All build tools pre-installed
|
|
||||||
|
|
||||||
### Step 2: Tag for Your Registry
|
|
||||||
|
|
||||||
Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Login to Your Registry
|
|
||||||
|
|
||||||
If not already logged in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login gitea.tourolle.paris
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Push to Registry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Complete One-Liner
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verifying the Build
|
|
||||||
|
|
||||||
Check that the image was pushed successfully:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List images in your registry (depends on registry API support)
|
|
||||||
docker search gitea.tourolle.paris/dtourolle/jellytau-builder
|
|
||||||
|
|
||||||
# Or pull and test locally
|
|
||||||
docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
|
|
||||||
```
|
|
||||||
|
|
||||||
## Using in CI/CD
|
|
||||||
|
|
||||||
The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.
|
|
||||||
|
|
||||||
## Updating the Builder Image
|
|
||||||
|
|
||||||
When dependencies change (new Rust version, Android SDK update, etc.):
|
|
||||||
|
|
||||||
1. Update `Dockerfile.builder` with the new configuration
|
|
||||||
2. Rebuild and push with a new tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
|
|
||||||
docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Update the workflow to use the new tag:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
container:
|
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Image Contents
|
|
||||||
|
|
||||||
The builder image includes:
|
|
||||||
|
|
||||||
- **Base OS**: Ubuntu 24.04
|
|
||||||
- **Languages**:
|
|
||||||
- Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
|
|
||||||
- Node.js 20.x
|
|
||||||
- OpenJDK 17 (for Android)
|
|
||||||
- **Tools**:
|
|
||||||
- Bun package manager
|
|
||||||
- Android SDK 34
|
|
||||||
- Android NDK 27.0.11902837
|
|
||||||
- Build essentials (gcc, make, etc.)
|
|
||||||
- Git, curl, wget
|
|
||||||
- libssl, libclang development libraries
|
|
||||||
- **Pre-configured**:
|
|
||||||
- Rust toolchain components (rustfmt, clippy)
|
|
||||||
- Android SDK/NDK environment variables
|
|
||||||
- All paths optimized for building
|
|
||||||
|
|
||||||
## Build Time
|
|
||||||
|
|
||||||
First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK).
|
|
||||||
Subsequent builds are cached and take seconds.
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
The built image is approximately **4-5 GB**. Ensure your registry has sufficient storage.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Image not found" in CI
|
|
||||||
- Verify the image name matches exactly in the workflow
|
|
||||||
- Check that the image was successfully pushed: `docker push` output should show successful layers
|
|
||||||
- Ensure Gitea has access to your registry (check network/firewall)
|
|
||||||
|
|
||||||
### Build fails with "command not found"
|
|
||||||
- The image may not have finished pushing. Wait a few moments and retry the CI job.
|
|
||||||
- Check that all layers were pushed successfully in the push output.
|
|
||||||
|
|
||||||
### Registry authentication in CI
|
|
||||||
If your registry requires credentials in CI:
|
|
||||||
1. Create a deploy token in your registry
|
|
||||||
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
|
|
||||||
3. Use in workflow:
|
|
||||||
```yaml
|
|
||||||
- name: Login to Registry
|
|
||||||
run: |
|
|
||||||
docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Docker Build Documentation](https://docs.docker.com/build/)
|
|
||||||
- [Docker Push Documentation](https://docs.docker.com/engine/reference/commandline/push/)
|
|
||||||
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
|
|
||||||
282
DOCKER.md
282
DOCKER.md
@ -1,282 +0,0 @@
|
|||||||
# Docker & CI/CD Setup for JellyTau
|
|
||||||
|
|
||||||
This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The setup includes:
|
|
||||||
- **Dockerfile.builder**: Pre-built image with all dependencies (push to your registry)
|
|
||||||
- **Dockerfile**: Multi-stage build for local testing and building
|
|
||||||
- **docker-compose.yml**: Orchestration for local development and testing
|
|
||||||
- **.gitea/workflows/build-and-test.yml**: Automated CI/CD pipeline using pre-built builder image
|
|
||||||
|
|
||||||
### Quick Start
|
|
||||||
|
|
||||||
**For CI/CD (Gitea Actions)**:
|
|
||||||
1. Build and push builder image (see [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md))
|
|
||||||
2. Push to master branch - workflow runs automatically
|
|
||||||
3. Check Actions tab for results and APK artifacts
|
|
||||||
|
|
||||||
**For Local Testing**:
|
|
||||||
```bash
|
|
||||||
docker-compose run test # Run tests
|
|
||||||
docker-compose run android-build # Build APK
|
|
||||||
docker-compose run dev # Interactive shell
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Usage
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Docker Engine 20.10+
|
|
||||||
- Docker Compose 2.0+ (if using docker-compose)
|
|
||||||
- At least 10GB free disk space (for Android SDK and build artifacts)
|
|
||||||
|
|
||||||
### Building the Docker Image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the complete image
|
|
||||||
docker build -t jellytau:latest .
|
|
||||||
|
|
||||||
# Build specific target
|
|
||||||
docker build -t jellytau:test --target test .
|
|
||||||
docker build -t jellytau:android --target android-build .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Docker Compose
|
|
||||||
|
|
||||||
#### Run Tests Only
|
|
||||||
```bash
|
|
||||||
docker-compose run test
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Install all dependencies
|
|
||||||
2. Run frontend tests (Vitest)
|
|
||||||
3. Run Rust backend tests
|
|
||||||
4. Report results
|
|
||||||
|
|
||||||
#### Build Android APK
|
|
||||||
```bash
|
|
||||||
docker-compose run android-build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Run tests first (depends on test service)
|
|
||||||
2. If tests pass, build the Android APK
|
|
||||||
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`
|
|
||||||
|
|
||||||
#### Interactive Development
|
|
||||||
```bash
|
|
||||||
docker-compose run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts an interactive shell with all development tools available. From here you can:
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
bun run build
|
|
||||||
bun test
|
|
||||||
bun run tauri android build --apk true
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run All Services in Sequence
|
|
||||||
```bash
|
|
||||||
docker-compose up --abort-on-container-exit
|
|
||||||
```
|
|
||||||
|
|
||||||
### Extracting Build Artifacts
|
|
||||||
|
|
||||||
After a successful build, APK files are located in:
|
|
||||||
```
|
|
||||||
src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy to your host machine:
|
|
||||||
```bash
|
|
||||||
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gitea Act CI/CD Pipeline
|
|
||||||
|
|
||||||
The `.gitea/workflows/build-and-test.yml` workflow automates:
|
|
||||||
|
|
||||||
**Single Job**: Runs on every push to `master` and PRs
|
|
||||||
- Uses pre-built builder image (no setup time)
|
|
||||||
- Installs project dependencies
|
|
||||||
- Runs frontend tests (Vitest)
|
|
||||||
- Runs Rust backend tests
|
|
||||||
- Builds the frontend
|
|
||||||
- Builds the Android APK
|
|
||||||
- Uploads APK as artifact (30-day retention)
|
|
||||||
|
|
||||||
The workflow skips markdown files to avoid unnecessary builds.
|
|
||||||
|
|
||||||
### Workflow Triggers
|
|
||||||
|
|
||||||
The workflow runs on:
|
|
||||||
- Push to `master` or `main` branches
|
|
||||||
- Pull requests to `master` or `main` branches
|
|
||||||
- Can be extended with: `workflow_dispatch` for manual triggers
|
|
||||||
|
|
||||||
### Setting Up the Builder Image
|
|
||||||
|
|
||||||
Before using the CI/CD pipeline, you must build and push the builder image:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
|
||||||
|
|
||||||
# Tag for your registry
|
|
||||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
|
|
||||||
# Push to registry
|
|
||||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
See [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md) for detailed instructions.
|
|
||||||
|
|
||||||
### Setting Up Gitea Act
|
|
||||||
|
|
||||||
1. **Ensure builder image is pushed** (see above)
|
|
||||||
|
|
||||||
2. **Push to Gitea repository**:
|
|
||||||
The workflow will automatically trigger on push to `master` or pull requests
|
|
||||||
|
|
||||||
3. **View workflow runs in Gitea UI**:
|
|
||||||
- Navigate to your repository
|
|
||||||
- Go to Actions tab
|
|
||||||
- Click on workflow runs to see logs
|
|
||||||
|
|
||||||
4. **Test locally** (optional):
|
|
||||||
```bash
|
|
||||||
# Install act if needed
|
|
||||||
curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
|
|
||||||
|
|
||||||
# Run locally (requires builder image to be available)
|
|
||||||
./act push --file .gitea/workflows/build-and-test.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Customizing the Workflow
|
|
||||||
|
|
||||||
#### Modify Build Triggers
|
|
||||||
Edit `.gitea/workflows/build-and-test.yml` to change when builds run:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- develop # Add more branches
|
|
||||||
paths:
|
|
||||||
- 'src/**' # Only run if src/ changes
|
|
||||||
- 'src-tauri/**' # Only run if Rust code changes
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Add Notifications
|
|
||||||
Add Slack, Discord, or email notifications on build completion:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Notify on success
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
curl -X POST https://slack-webhook-url...
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Customize APK Upload
|
|
||||||
Modify artifact retention or add to cloud storage:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload APK to S3
|
|
||||||
uses: actions/s3-sync@v1
|
|
||||||
with:
|
|
||||||
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
|
|
||||||
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
|
|
||||||
aws_bucket: my-apk-bucket
|
|
||||||
source_dir: src-tauri/gen/android/app/build/outputs/apk/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Setup in CI
|
|
||||||
|
|
||||||
### Secret Variables
|
|
||||||
To use secrets in the workflow, set them in Gitea:
|
|
||||||
|
|
||||||
1. Go to Repository Settings → Secrets
|
|
||||||
2. Add secrets like:
|
|
||||||
- `AWS_ACCESS_KEY` for S3 uploads
|
|
||||||
- `SLACK_WEBHOOK_URL` for notifications
|
|
||||||
- `GITHUB_TOKEN` for releases (pre-configured)
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Out of Memory During Build
|
|
||||||
Android builds are memory-intensive. If you get OOM errors:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Limit memory in docker-compose
|
|
||||||
services:
|
|
||||||
android-build:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 6G
|
|
||||||
```
|
|
||||||
|
|
||||||
Or increase Docker's memory allocation in Docker Desktop settings.
|
|
||||||
|
|
||||||
### Android SDK Download Timeout
|
|
||||||
If downloads timeout, increase timeout or download manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# In container, with longer timeout
|
|
||||||
timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Compilation Errors
|
|
||||||
Make sure Rust is updated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rustup update
|
|
||||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache Issues
|
|
||||||
Clear Docker cache and rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose down -v # Remove volumes
|
|
||||||
docker system prune # Clean up dangling images
|
|
||||||
docker-compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Tips
|
|
||||||
|
|
||||||
1. **Cache Reuse**: Both Docker and Gitea Act cache dependencies across runs
|
|
||||||
2. **Parallel Steps**: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
|
|
||||||
3. **Incremental Builds**: Rust and Node caches persist between runs
|
|
||||||
4. **Docker Buildkit**: Enable for faster builds:
|
|
||||||
```bash
|
|
||||||
DOCKER_BUILDKIT=1 docker build .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
|
|
||||||
- NDK is downloaded from official Google servers (verified via HTTPS)
|
|
||||||
- No credentials are stored in the Dockerfile
|
|
||||||
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
|
|
||||||
- Lock dependency versions in `Cargo.toml` and `package.json`
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Test locally with `docker-compose up`
|
|
||||||
2. Push to your Gitea repository
|
|
||||||
3. Monitor workflow runs in the Actions tab
|
|
||||||
4. Configure secrets in repository settings for production builds
|
|
||||||
5. Set up artifact retention policies (currently 30 days)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
|
||||||
- [Android Build Tools](https://developer.android.com/studio/command-line)
|
|
||||||
- [Tauri Android Guide](https://tauri.app/v1/guides/building/android)
|
|
||||||
110
Dockerfile
110
Dockerfile
@ -1,110 +0,0 @@
|
|||||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
|
||||||
FROM ubuntu:24.04 AS builder
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=34 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
# Build essentials
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
# JDK for Android
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
# Android build tools
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
# Additional development tools
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Setup Android SDK components
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
# Create working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy project files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Install Node.js dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Install Rust dependencies
|
|
||||||
RUN cd src-tauri && cargo fetch && cd ..
|
|
||||||
|
|
||||||
# Build stage - Tests
|
|
||||||
FROM builder AS test
|
|
||||||
WORKDIR /app
|
|
||||||
RUN echo "Running tests..." && \
|
|
||||||
bun run test && \
|
|
||||||
cd src-tauri && cargo test && cd .. && \
|
|
||||||
echo "All tests passed!"
|
|
||||||
|
|
||||||
# Build stage - APK
|
|
||||||
FROM builder AS android-build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN cd src-tauri && cargo fetch && cd .. && \
|
|
||||||
echo "Building Android APK..." && \
|
|
||||||
bun run build && \
|
|
||||||
bun run tauri android build --apk true && \
|
|
||||||
echo "APK build complete!"
|
|
||||||
|
|
||||||
# Final output stage
|
|
||||||
FROM ubuntu:24.04 AS final
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
android-sdk-platform-tools \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=android-build /app/src-tauri/gen/android/app/build/outputs/apk /app/apk
|
|
||||||
|
|
||||||
VOLUME ["/app/apk"]
|
|
||||||
CMD ["/bin/bash", "-c", "echo 'APK files are available in /app/apk' && ls -lh /app/apk/"]
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# JellyTau Builder Image
|
|
||||||
# Pre-built image with all dependencies for building and testing
|
|
||||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
|
||||||
|
|
||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
|
||||||
NDK_VERSION=27.0.11902837 \
|
|
||||||
SDK_VERSION=34 \
|
|
||||||
RUST_BACKTRACE=1 \
|
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
|
||||||
CARGO_HOME=/root/.cargo
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
wget \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
unzip \
|
|
||||||
openjdk-17-jdk-headless \
|
|
||||||
pkg-config \
|
|
||||||
libssl-dev \
|
|
||||||
libclang-dev \
|
|
||||||
llvm-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js 20.x from NodeSource
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
|
||||||
apt-get install -y --no-install-recommends nodejs && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Bun
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
|
||||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
|
|
||||||
# Install Rust using rustup
|
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
|
||||||
. $HOME/.cargo/env && \
|
|
||||||
rustup target add aarch64-linux-android && \
|
|
||||||
rustup target add armv7-linux-androideabi && \
|
|
||||||
rustup target add x86_64-linux-android && \
|
|
||||||
rustup component add rustfmt clippy
|
|
||||||
|
|
||||||
# Setup Android SDK
|
|
||||||
RUN mkdir -p $ANDROID_HOME && \
|
|
||||||
mkdir -p /root/.android && \
|
|
||||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
|
||||||
echo 'count=0' >> /root/.android/repositories.cfg
|
|
||||||
|
|
||||||
# Download and setup Android Command Line Tools
|
|
||||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
|
||||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
|
||||||
rm /tmp/cmdline-tools.zip && \
|
|
||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Install Android SDK components
|
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|
||||||
"platforms;android-$SDK_VERSION" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"ndk;$NDK_VERSION" \
|
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
|
||||||
|
|
||||||
# Set NDK environment variable
|
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/bash"]
|
|
||||||
232
FIXES_SUMMARY.md
232
FIXES_SUMMARY.md
@ -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
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# Test service - runs tests only
|
|
||||||
test:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: test
|
|
||||||
container_name: jellytau-test
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
command: bash -c "bun test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
|
|
||||||
|
|
||||||
# Android build service - builds APK after tests pass
|
|
||||||
android-build:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: android-build
|
|
||||||
container_name: jellytau-android-build
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- android-cache:/root/.cargo
|
|
||||||
- android-bun-cache:/root/.bun
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
depends_on:
|
|
||||||
- test
|
|
||||||
ports:
|
|
||||||
- "5172:5172" # In case you want to run dev server
|
|
||||||
|
|
||||||
# Development container - for interactive development
|
|
||||||
dev:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: builder
|
|
||||||
container_name: jellytau-dev
|
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- cargo-cache:/root/.cargo
|
|
||||||
- bun-cache:/root/.bun
|
|
||||||
- node-modules:/app/node_modules
|
|
||||||
environment:
|
|
||||||
- RUST_BACKTRACE=1
|
|
||||||
- ANDROID_HOME=/opt/android-sdk
|
|
||||||
- NDK_HOME=/opt/android-sdk/ndk/27.0.11902837
|
|
||||||
working_dir: /app
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
command: /bin/bash
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
cargo-cache:
|
|
||||||
bun-cache:
|
|
||||||
android-cache:
|
|
||||||
android-bun-cache:
|
|
||||||
node-modules:
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
# Build & Release Workflow
|
|
||||||
|
|
||||||
This document explains the automated build and release process for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically:
|
|
||||||
1. ✅ Runs all tests (frontend + Rust)
|
|
||||||
2. ✅ Builds Linux binaries (AppImage + DEB)
|
|
||||||
3. ✅ Builds Android APK and AAB
|
|
||||||
4. ✅ Creates releases with artifacts
|
|
||||||
5. ✅ Tags releases with version numbers
|
|
||||||
|
|
||||||
## Workflow Triggers
|
|
||||||
|
|
||||||
### Automatic Trigger
|
|
||||||
When you push a version tag:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow automatically:
|
|
||||||
1. Runs tests
|
|
||||||
2. Builds both platforms
|
|
||||||
3. Creates a GitHub release with artifacts
|
|
||||||
4. Tags it as release/prerelease based on version
|
|
||||||
|
|
||||||
### Manual Trigger
|
|
||||||
In Gitea Actions UI:
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click **Build & Release** workflow
|
|
||||||
3. Click **Run workflow**
|
|
||||||
4. Optionally specify a version
|
|
||||||
5. Workflow runs without creating a release
|
|
||||||
|
|
||||||
## Version Tagging
|
|
||||||
|
|
||||||
### Format
|
|
||||||
Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `v1.0.0` - Release version
|
|
||||||
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
|
|
||||||
- `v1.0.0-beta` - Beta version (marked as prerelease)
|
|
||||||
- `v0.1.0-alpha` - Alpha version (marked as prerelease)
|
|
||||||
|
|
||||||
### Creating a Release
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create and push a version tag
|
|
||||||
git tag v1.0.0 -m "Release version 1.0.0"
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# Or create from main branch
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0" main
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
### Release Status
|
|
||||||
|
|
||||||
Versions containing `rc`, `beta`, or `alpha` are marked as **prerelease**:
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0-rc1 # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-beta # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0-alpha # ⚠️ Prerelease
|
|
||||||
git tag v1.0.0 # ✅ Full release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Steps
|
|
||||||
|
|
||||||
### 1. Test Phase
|
|
||||||
Runs on all tags and manual triggers:
|
|
||||||
- Frontend tests (`vitest`)
|
|
||||||
- Rust tests (`cargo test`)
|
|
||||||
- TypeScript type checking
|
|
||||||
|
|
||||||
**Failure:** Stops workflow, no build/release
|
|
||||||
|
|
||||||
### 2. Build Linux Phase
|
|
||||||
Runs after tests pass:
|
|
||||||
- Installs system dependencies
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **AppImage** - Universal Linux binary
|
|
||||||
- **DEB** - Debian/Ubuntu package
|
|
||||||
|
|
||||||
**Output:** `artifacts/linux/`
|
|
||||||
|
|
||||||
### 3. Build Android Phase
|
|
||||||
Runs in parallel with Linux build:
|
|
||||||
- Installs Android SDK/NDK
|
|
||||||
- Configures Rust for Android targets
|
|
||||||
- Builds with Tauri
|
|
||||||
- Generates:
|
|
||||||
- **APK** - Android app package (installable)
|
|
||||||
- **AAB** - Android App Bundle (for Play Store)
|
|
||||||
|
|
||||||
**Output:** `artifacts/android/`
|
|
||||||
|
|
||||||
### 4. Create Release Phase
|
|
||||||
Runs after both builds succeed (only on version tags):
|
|
||||||
- Prepares release notes
|
|
||||||
- Downloads build artifacts
|
|
||||||
- Creates GitHub/Gitea release
|
|
||||||
- Uploads all artifacts
|
|
||||||
- Tags as prerelease if applicable
|
|
||||||
|
|
||||||
## Artifacts
|
|
||||||
|
|
||||||
### Linux Artifacts
|
|
||||||
|
|
||||||
#### AppImage
|
|
||||||
- **File:** `jellytau_*.AppImage`
|
|
||||||
- **Size:** ~100-150 MB
|
|
||||||
- **Use:** Run directly on any Linux distro
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
chmod +x jellytau_*.AppImage
|
|
||||||
./jellytau_*.AppImage
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DEB Package
|
|
||||||
- **File:** `jellytau_*.deb`
|
|
||||||
- **Size:** ~80-120 MB
|
|
||||||
- **Use:** Install on Debian/Ubuntu/similar
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
sudo dpkg -i jellytau_*.deb
|
|
||||||
jellytau
|
|
||||||
```
|
|
||||||
|
|
||||||
### Android Artifacts
|
|
||||||
|
|
||||||
#### APK
|
|
||||||
- **File:** `jellytau-release.apk`
|
|
||||||
- **Size:** ~60-100 MB
|
|
||||||
- **Use:** Direct installation on Android devices
|
|
||||||
- **Installation:**
|
|
||||||
```bash
|
|
||||||
adb install jellytau-release.apk
|
|
||||||
# Or sideload via file manager
|
|
||||||
```
|
|
||||||
|
|
||||||
#### AAB (Android App Bundle)
|
|
||||||
- **File:** `jellytau-release.aab`
|
|
||||||
- **Size:** ~50-90 MB
|
|
||||||
- **Use:** Upload to Google Play Console
|
|
||||||
- **Note:** Cannot be installed directly; for Play Store distribution
|
|
||||||
|
|
||||||
## Release Notes
|
|
||||||
|
|
||||||
Release notes are automatically generated with:
|
|
||||||
- Version number
|
|
||||||
- Download links
|
|
||||||
- Installation instructions
|
|
||||||
- System requirements
|
|
||||||
- Known issues link
|
|
||||||
- Changelog reference
|
|
||||||
|
|
||||||
## Build Matrix
|
|
||||||
|
|
||||||
| Platform | OS | Architecture | Format |
|
|
||||||
|----------|----|----|--------|
|
|
||||||
| **Linux** | Any | x86_64 | AppImage, DEB |
|
|
||||||
| **Android** | 8.0+ | arm64, armv7, x86_64 | APK, AAB |
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Build Fails During Test Phase
|
|
||||||
1. Check test output in Gitea Actions
|
|
||||||
2. Run tests locally: `bun run test` and `bun run test:rust`
|
|
||||||
3. Fix failing tests
|
|
||||||
4. Create new tag with fixed code
|
|
||||||
|
|
||||||
### Linux Build Fails
|
|
||||||
1. Check system dependencies installed
|
|
||||||
2. Verify Tauri configuration
|
|
||||||
3. Check cargo dependencies
|
|
||||||
4. Clear cache: Delete `.cargo` and `target/` directories
|
|
||||||
|
|
||||||
### Android Build Fails
|
|
||||||
1. Check Android SDK/NDK setup
|
|
||||||
2. Verify Java 17 is installed
|
|
||||||
3. Check Rust Android targets: `rustup target list`
|
|
||||||
4. Clear cache and rebuild
|
|
||||||
|
|
||||||
### Release Not Created
|
|
||||||
1. Tag must start with `v` (e.g., `v1.0.0`)
|
|
||||||
2. Tests must pass
|
|
||||||
3. Both builds must succeed
|
|
||||||
4. Check workflow logs for errors
|
|
||||||
|
|
||||||
## GitHub Release vs Gitea
|
|
||||||
|
|
||||||
The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:
|
|
||||||
|
|
||||||
1. Workflow creates artifacts
|
|
||||||
2. Artifacts are available in Actions artifacts
|
|
||||||
3. Download and manually create Gitea release, or
|
|
||||||
4. Set up Gitea API integration to auto-publish
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
### Change Release Notes Template
|
|
||||||
|
|
||||||
Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Prepare release notes
|
|
||||||
id: release_notes
|
|
||||||
run: |
|
|
||||||
# Add your custom release notes format here
|
|
||||||
echo "Custom notes" > release_notes.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add New Platforms
|
|
||||||
|
|
||||||
To add macOS or Windows builds:
|
|
||||||
|
|
||||||
1. Add new `build-{platform}` job
|
|
||||||
2. Set appropriate `runs-on` runner
|
|
||||||
3. Add platform-specific dependencies
|
|
||||||
4. Update artifact upload
|
|
||||||
5. Include in `needs: [build-linux, build-android, build-{platform}]`
|
|
||||||
|
|
||||||
### Change Build Targets
|
|
||||||
|
|
||||||
Modify Tauri configuration or add targets:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Build for Linux
|
|
||||||
run: |
|
|
||||||
# Add target specification
|
|
||||||
bun run tauri build -- --target x86_64-unknown-linux-gnu
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Check Status
|
|
||||||
1. Go to **Actions** tab in Gitea
|
|
||||||
2. View **Build & Release** workflow runs
|
|
||||||
3. Click specific run to see logs
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
Set up notifications for:
|
|
||||||
- Build failures
|
|
||||||
- Release creation
|
|
||||||
- Tag pushes
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
### Build Times (Approximate)
|
|
||||||
- Test phase: 5-10 minutes
|
|
||||||
- Linux build: 10-15 minutes
|
|
||||||
- Android build: 15-20 minutes
|
|
||||||
- Total: 30-45 minutes
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
Workflow caches:
|
|
||||||
- Rust dependencies (cargo)
|
|
||||||
- Bun node_modules
|
|
||||||
- Android SDK components
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
The workflow uses:
|
|
||||||
- `GITHUB_TOKEN` - Built-in, no setup needed
|
|
||||||
- No credentials needed for Gitea
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
To verify build integrity:
|
|
||||||
1. Download artifacts
|
|
||||||
2. Verify signatures (if implemented)
|
|
||||||
3. Check file hashes
|
|
||||||
4. Test on target platform
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
|
||||||
2. Tag releases in git
|
|
||||||
3. Update CHANGELOG.md before tagging
|
|
||||||
4. Include release notes in tag message
|
|
||||||
|
|
||||||
### Testing Before Release
|
|
||||||
```bash
|
|
||||||
# Local testing before release
|
|
||||||
bun run test # Frontend tests
|
|
||||||
bun run test:rust # Rust tests
|
|
||||||
bun run check # Type checking
|
|
||||||
bun run tauri build # Local build test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
|
||||||
2. Update [README.md](../README.md) with new features
|
|
||||||
3. Document breaking changes
|
|
||||||
4. Add migration guide if needed
|
|
||||||
|
|
||||||
## Example Release Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
|
|
||||||
vim package.json
|
|
||||||
vim src-tauri/tauri.conf.json
|
|
||||||
|
|
||||||
# 2. Update CHANGELOG
|
|
||||||
vim CHANGELOG.md
|
|
||||||
|
|
||||||
# 3. Commit changes
|
|
||||||
git add .
|
|
||||||
git commit -m "Bump version to v1.0.0"
|
|
||||||
|
|
||||||
# 4. Create annotated tag
|
|
||||||
git tag -a v1.0.0 -m "Release version 1.0.0
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Feature 1
|
|
||||||
- Feature 2
|
|
||||||
|
|
||||||
Fixes:
|
|
||||||
- Fix 1
|
|
||||||
- Fix 2"
|
|
||||||
|
|
||||||
# 5. Push tag to trigger workflow
|
|
||||||
git push origin v1.0.0
|
|
||||||
|
|
||||||
# 6. Monitor workflow in Gitea Actions
|
|
||||||
# Wait for tests → Linux build → Android build → Release
|
|
||||||
|
|
||||||
# 7. Download artifacts and test
|
|
||||||
# Visit release page and verify downloads
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Tauri Documentation](https://tauri.app/)
|
|
||||||
- [Semantic Versioning](https://semver.org/)
|
|
||||||
- [GitHub Release Best Practices](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
|
|
||||||
- [Android App Bundle](https://developer.android.com/guide/app-bundle)
|
|
||||||
- [AppImage Documentation](https://docs.appimage.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
1327
docs/TRACEABILITY.md
1327
docs/TRACEABILITY.md
File diff suppressed because it is too large
Load Diff
@ -1,288 +0,0 @@
|
|||||||
# Requirement Traceability CI/CD Pipeline
|
|
||||||
|
|
||||||
This document explains the automated requirement traceability validation system for JellyTau.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:
|
|
||||||
- ✅ Requirements are implemented with clear traceability
|
|
||||||
- ✅ No requirement coverage regressions
|
|
||||||
- ✅ Code changes are linked to specific requirements
|
|
||||||
- ✅ Quality metrics are tracked over time
|
|
||||||
|
|
||||||
## Gitea Actions Workflows
|
|
||||||
|
|
||||||
Two workflows are configured in `.gitea/workflows/`:
|
|
||||||
|
|
||||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
|
||||||
Gitea-native workflow with:
|
|
||||||
- ✅ Automatic trace extraction
|
|
||||||
- ✅ Coverage validation against minimum threshold (50%)
|
|
||||||
- ✅ Modified file checking
|
|
||||||
- ✅ Artifact preservation
|
|
||||||
- ✅ Summary reports
|
|
||||||
|
|
||||||
**Runs on:** Every push and pull request
|
|
||||||
|
|
||||||
### 2. `traceability.yml` (Alternative)
|
|
||||||
GitHub-compatible workflow with additional features:
|
|
||||||
- Pull request comments with coverage stats
|
|
||||||
- GitHub-specific integrations
|
|
||||||
|
|
||||||
## What Gets Validated
|
|
||||||
|
|
||||||
### 1. Trace Extraction
|
|
||||||
```bash
|
|
||||||
bun run traces:json > traces-report.json
|
|
||||||
```
|
|
||||||
Extracts all TRACES comments from:
|
|
||||||
- TypeScript files (`src/**/*.ts`)
|
|
||||||
- Svelte components (`src/**/*.svelte`)
|
|
||||||
- Rust code (`src-tauri/src/**/*.rs`)
|
|
||||||
- Test files
|
|
||||||
|
|
||||||
### 2. Coverage Thresholds
|
|
||||||
The workflow checks:
|
|
||||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
|
||||||
- **Requirements by type:**
|
|
||||||
- UR (User): 23+ of 39
|
|
||||||
- IR (Integration): 5+ of 24
|
|
||||||
- DR (Development): 28+ of 48
|
|
||||||
- JA (Jellyfin API): 0+ of 3
|
|
||||||
|
|
||||||
If coverage drops below threshold, the workflow **fails** and blocks merge.
|
|
||||||
|
|
||||||
### 3. Modified File Checking
|
|
||||||
On pull requests, the workflow:
|
|
||||||
1. Detects all changed TypeScript/Svelte/Rust files
|
|
||||||
2. Warns if new/modified files lack TRACES comments
|
|
||||||
3. Suggests the TRACES format for missing comments
|
|
||||||
|
|
||||||
## How to Add Traces to New Code
|
|
||||||
|
|
||||||
When you add new code or modify existing code, include TRACES comments:
|
|
||||||
|
|
||||||
### TypeScript/Svelte Example
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
export function handlePlayback() {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust Example
|
|
||||||
```rust
|
|
||||||
/// TRACES: UR-005 | DR-001
|
|
||||||
pub fn player_state_changed(state: PlayerState) {
|
|
||||||
// Implementation...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Example
|
|
||||||
```rust
|
|
||||||
// TRACES: UR-005 | DR-001 | UT-026, UT-027
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
// Tests...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TRACES Format
|
|
||||||
|
|
||||||
```
|
|
||||||
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- `UR-###` - User Requirements (features users see)
|
|
||||||
- `IR-###` - Integration Requirements (API/platform integration)
|
|
||||||
- `DR-###` - Development Requirements (internal architecture)
|
|
||||||
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- `// TRACES: UR-005` - Single requirement
|
|
||||||
- `// TRACES: UR-005, UR-026` - Multiple of same type
|
|
||||||
- `// TRACES: UR-005 | DR-029` - Multiple types
|
|
||||||
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex
|
|
||||||
|
|
||||||
## Workflow Behavior
|
|
||||||
|
|
||||||
### On Push to Main Branch
|
|
||||||
1. ✅ Extracts all traces from code
|
|
||||||
2. ✅ Validates coverage is >= 50%
|
|
||||||
3. ✅ Generates full traceability report
|
|
||||||
4. ✅ Saves report as artifact
|
|
||||||
|
|
||||||
### On Pull Request
|
|
||||||
1. ✅ Extracts all traces
|
|
||||||
2. ✅ Validates coverage >= 50%
|
|
||||||
3. ✅ Checks modified files for TRACES
|
|
||||||
4. ✅ Warns if new code lacks TRACES
|
|
||||||
5. ✅ Suggests proper format
|
|
||||||
6. ✅ Generates report artifact
|
|
||||||
|
|
||||||
### Failure Scenarios
|
|
||||||
The workflow **fails** (blocks merge) if:
|
|
||||||
- Coverage drops below 50%
|
|
||||||
- JSON extraction fails
|
|
||||||
- Invalid trace format
|
|
||||||
|
|
||||||
The workflow **warns** (but doesn't block) if:
|
|
||||||
- New files lack TRACES comments
|
|
||||||
- Coverage drops (but still above threshold)
|
|
||||||
|
|
||||||
## Viewing Reports
|
|
||||||
|
|
||||||
### In Gitea Actions UI
|
|
||||||
1. Go to **Actions** tab
|
|
||||||
2. Click the **Traceability Validation** workflow run
|
|
||||||
3. Download **traceability-reports** artifact
|
|
||||||
4. View:
|
|
||||||
- `traces-report.json` - Raw trace data
|
|
||||||
- `docs/TRACEABILITY.md` - Formatted report
|
|
||||||
|
|
||||||
### Locally
|
|
||||||
```bash
|
|
||||||
# Extract current traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Generate full report
|
|
||||||
bun run traces:markdown
|
|
||||||
cat docs/TRACEABILITY.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage Goals
|
|
||||||
|
|
||||||
### Current Status
|
|
||||||
- Overall: 51% (56/114)
|
|
||||||
- UR: 59% (23/39)
|
|
||||||
- IR: 21% (5/24)
|
|
||||||
- DR: 58% (28/48)
|
|
||||||
- JA: 0% (0/3)
|
|
||||||
|
|
||||||
### Targets
|
|
||||||
- **Short term** (Sprint): Maintain ≥50% overall
|
|
||||||
- **Medium term** (Month): Reach 70% overall coverage
|
|
||||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
|
||||||
- IR requirements (API clients)
|
|
||||||
- JA requirements (Jellyfin API endpoints)
|
|
||||||
- Remaining UR/DR requirements
|
|
||||||
|
|
||||||
## Improving Coverage
|
|
||||||
|
|
||||||
### For Missing User Requirements (UR)
|
|
||||||
1. Review [README.md](../README.md) for unimplemented features
|
|
||||||
2. Add TRACES to code that implements them
|
|
||||||
3. Focus on high-priority features (High/Medium priority)
|
|
||||||
|
|
||||||
### For Missing Integration Requirements (IR)
|
|
||||||
1. Add TRACES to Jellyfin API client methods
|
|
||||||
2. Add TRACES to platform-specific backends (Android/Linux)
|
|
||||||
3. Link to corresponding Jellyfin API endpoints
|
|
||||||
|
|
||||||
### For Missing Development Requirements (DR)
|
|
||||||
1. Add TRACES to UI components in `src/lib/components/`
|
|
||||||
2. Add TRACES to composables in `src/lib/composables/`
|
|
||||||
3. Add TRACES to player backend in `src-tauri/src/player/`
|
|
||||||
|
|
||||||
### For Jellyfin API Requirements (JA)
|
|
||||||
1. Add TRACES to Jellyfin API wrapper methods
|
|
||||||
2. Document which endpoints map to which requirements
|
|
||||||
3. Link to Jellyfin API documentation
|
|
||||||
|
|
||||||
## Example PR Checklist
|
|
||||||
|
|
||||||
When submitting a pull request:
|
|
||||||
|
|
||||||
- [ ] All new code has TRACES comments linking to requirements
|
|
||||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
|
||||||
- [ ] Workflow passes (coverage ≥ 50%)
|
|
||||||
- [ ] No coverage regressions
|
|
||||||
- [ ] Artifact traceability report was generated
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Coverage below minimum threshold"
|
|
||||||
**Problem:** Workflow fails with coverage < 50%
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Run `bun run traces:json` locally
|
|
||||||
2. Check which requirements are traced
|
|
||||||
3. Add TRACES to untraced code sections
|
|
||||||
4. Re-run extraction to verify
|
|
||||||
|
|
||||||
### "New files without TRACES"
|
|
||||||
**Problem:** Workflow warns about new files lacking TRACES
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Add TRACES comments to all new code
|
|
||||||
2. Format: `// TRACES: UR-001 | DR-002`
|
|
||||||
3. Map code to specific requirements from README.md
|
|
||||||
4. Re-push
|
|
||||||
|
|
||||||
### "Invalid JSON format"
|
|
||||||
**Problem:** Trace extraction produces invalid JSON
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check for malformed TRACES comments
|
|
||||||
2. Run locally: `bun run traces:json`
|
|
||||||
3. Look for parsing errors
|
|
||||||
4. Fix and retry
|
|
||||||
|
|
||||||
## Integration with Development
|
|
||||||
|
|
||||||
### Before Committing
|
|
||||||
```bash
|
|
||||||
# Check your traces
|
|
||||||
bun run traces:json | jq '.byType'
|
|
||||||
|
|
||||||
# Regenerate report
|
|
||||||
bun run traces:markdown
|
|
||||||
|
|
||||||
# Verify traces syntax
|
|
||||||
grep "TRACES:" src/**/*.ts src/**/*.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
### In Your IDE
|
|
||||||
Add a file watcher to regenerate traces on save:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fileWatcher.watchPatterns": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.svelte",
|
|
||||||
"src-tauri/src/**/*.rs"
|
|
||||||
],
|
|
||||||
"fileWatcher.command": "bun run traces:markdown"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Git Hooks
|
|
||||||
Add a pre-push hook to validate traces:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
# .git/hooks/pre-push
|
|
||||||
bun run traces:json > /dev/null
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "❌ Invalid TRACES format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
|
||||||
- [Requirements Specification](../README.md#requirements-specification)
|
|
||||||
- [Traceability Matrix](./TRACEABILITY.md)
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For issues or questions:
|
|
||||||
1. Check this document
|
|
||||||
2. Review example traces in `src/lib/stores/`
|
|
||||||
3. Check existing TRACES comments for format
|
|
||||||
4. Review workflow logs in Gitea Actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** 2026-02-13
|
|
||||||
1846
package-lock.json
generated
1846
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,10 +23,7 @@
|
|||||||
"android:check": "./scripts/check-android.sh",
|
"android:check": "./scripts/check-android.sh",
|
||||||
"android:logs": "./scripts/logcat.sh",
|
"android:logs": "./scripts/logcat.sh",
|
||||||
"clean": "./scripts/clean.sh",
|
"clean": "./scripts/clean.sh",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri"
|
||||||
"traces": "bun run scripts/extract-traces.ts",
|
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md"
|
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -60,42 +60,6 @@ View Android logcat filtered for the app.
|
|||||||
./scripts/logcat.sh
|
./scripts/logcat.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Traceability & Documentation
|
|
||||||
|
|
||||||
### `extract-traces.ts`
|
|
||||||
Extract requirement IDs (TRACES) from source code and generate a traceability matrix mapping requirements to implementation locations.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run traces # Generate markdown report
|
|
||||||
bun run traces:json # Generate JSON report
|
|
||||||
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:
|
|
||||||
- Which code files implement which requirements
|
|
||||||
- Line numbers and code context
|
|
||||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
|
||||||
|
|
||||||
Example TRACES comment in code:
|
|
||||||
```typescript
|
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
|
||||||
function handlePlayback() { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
See [docs/TRACEABILITY.md](../docs/TRACEABILITY.md) for the latest generated mapping.
|
|
||||||
|
|
||||||
### CI/CD Validation
|
|
||||||
|
|
||||||
The traceability system is integrated with Gitea Actions CI/CD:
|
|
||||||
- Automatically validates TRACES on every push and pull request
|
|
||||||
- Enforces minimum 50% coverage threshold
|
|
||||||
- Warns if new code lacks TRACES comments
|
|
||||||
- Generates traceability reports automatically
|
|
||||||
|
|
||||||
For details, see:
|
|
||||||
- [Traceability CI Guide](../docs/TRACEABILITY_CI.md) - Full CI/CD documentation
|
|
||||||
- [TRACES Quick Reference](../TRACES_QUICK_REF.md) - Quick guide for adding TRACES
|
|
||||||
|
|
||||||
## Utility Scripts
|
## Utility Scripts
|
||||||
|
|
||||||
### `clean.sh`
|
### `clean.sh`
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build and push the JellyTau builder Docker image to your registry
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.tourolle.paris}"
|
|
||||||
REGISTRY_USER="${REGISTRY_USER:-dtourolle}"
|
|
||||||
IMAGE_NAME="jellytau-builder"
|
|
||||||
IMAGE_TAG="${1:-latest}"
|
|
||||||
FULL_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
echo "🐳 Building JellyTau Builder Image"
|
|
||||||
echo "=================================="
|
|
||||||
echo "Registry: $REGISTRY_HOST"
|
|
||||||
echo "User: $REGISTRY_USER"
|
|
||||||
echo "Image: $FULL_IMAGE_NAME"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Step 1: Build locally
|
|
||||||
echo "🔨 Building Docker image locally..."
|
|
||||||
docker build -f Dockerfile.builder -t ${IMAGE_NAME}:${IMAGE_TAG} .
|
|
||||||
|
|
||||||
# Step 2: Tag for registry
|
|
||||||
echo "🏷️ Tagging for registry..."
|
|
||||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
# Step 3: Login to registry (if not already logged in)
|
|
||||||
echo "🔐 Checking registry authentication..."
|
|
||||||
if ! docker info | grep -q "Username"; then
|
|
||||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
|
||||||
docker login ${REGISTRY_HOST}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Step 4: Push to registry
|
|
||||||
echo "📤 Pushing image to registry..."
|
|
||||||
docker push ${FULL_IMAGE_NAME}
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
|
|
||||||
echo ""
|
|
||||||
echo "Update your workflow to use:"
|
|
||||||
echo " container:"
|
|
||||||
echo " image: ${FULL_IMAGE_NAME}"
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Extract TRACES from source code and generate requirement mapping
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run scripts/extract-traces.ts
|
|
||||||
* bun run scripts/extract-traces.ts --format json
|
|
||||||
* bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { execSync } from "child_process";
|
|
||||||
|
|
||||||
interface TraceEntry {
|
|
||||||
file: string;
|
|
||||||
line: number;
|
|
||||||
context: string;
|
|
||||||
requirements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequirementMapping {
|
|
||||||
[reqId: string]: TraceEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TracesData {
|
|
||||||
timestamp: string;
|
|
||||||
totalFiles: number;
|
|
||||||
totalTraces: number;
|
|
||||||
requirements: RequirementMapping;
|
|
||||||
byType: {
|
|
||||||
UR: string[];
|
|
||||||
IR: string[];
|
|
||||||
DR: string[];
|
|
||||||
JA: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
|
||||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
|
||||||
|
|
||||||
function extractRequirementIds(tracesString: string): string[] {
|
|
||||||
const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
|
|
||||||
return matches.map((m) => `${m[1]}-${m[2]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllSourceFiles(): string[] {
|
|
||||||
const baseDir = "/home/dtourolle/Development/JellyTau";
|
|
||||||
const patterns = ["src", "src-tauri/src"];
|
|
||||||
const files: string[] = [];
|
|
||||||
|
|
||||||
function walkDir(dir: string) {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
// Skip node_modules, target, build
|
|
||||||
if (
|
|
||||||
relativePath.includes("node_modules") ||
|
|
||||||
relativePath.includes("target") ||
|
|
||||||
relativePath.includes("build") ||
|
|
||||||
relativePath.includes(".git")
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
walkDir(fullPath);
|
|
||||||
} else if (
|
|
||||||
entry.name.endsWith(".ts") ||
|
|
||||||
entry.name.endsWith(".svelte") ||
|
|
||||||
entry.name.endsWith(".rs")
|
|
||||||
) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip directories we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const dir = path.join(baseDir, pattern);
|
|
||||||
if (fs.existsSync(dir)) {
|
|
||||||
walkDir(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTraces(): TracesData {
|
|
||||||
const requirementMap: RequirementMapping = {};
|
|
||||||
const byType: Record<string, Set<string>> = {
|
|
||||||
UR: new Set(),
|
|
||||||
IR: new Set(),
|
|
||||||
DR: new Set(),
|
|
||||||
JA: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let totalTraces = 0;
|
|
||||||
const baseDir = "/home/dtourolle/Development/JellyTau";
|
|
||||||
|
|
||||||
const files = getAllSourceFiles();
|
|
||||||
|
|
||||||
for (const fullPath of files) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(fullPath, "utf-8");
|
|
||||||
const lines = content.split("\n");
|
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
|
||||||
|
|
||||||
let match;
|
|
||||||
TRACES_PATTERN.lastIndex = 0;
|
|
||||||
|
|
||||||
while ((match = TRACES_PATTERN.exec(content)) !== null) {
|
|
||||||
const tracesStr = match[1];
|
|
||||||
const reqIds = extractRequirementIds(tracesStr);
|
|
||||||
|
|
||||||
if (reqIds.length === 0) continue;
|
|
||||||
|
|
||||||
// Find line number
|
|
||||||
const beforeMatch = content.substring(0, match.index);
|
|
||||||
const lineNum = beforeMatch.split("\n").length - 1;
|
|
||||||
|
|
||||||
// Get context (function/class name if available)
|
|
||||||
let context = "Unknown";
|
|
||||||
for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
|
|
||||||
const line = lines[i];
|
|
||||||
if (
|
|
||||||
line.includes("function ") ||
|
|
||||||
line.includes("export const ") ||
|
|
||||||
line.includes("pub fn ") ||
|
|
||||||
line.includes("pub enum ") ||
|
|
||||||
line.includes("pub struct ") ||
|
|
||||||
line.includes("impl ") ||
|
|
||||||
line.includes("async function ") ||
|
|
||||||
line.includes("class ") ||
|
|
||||||
line.includes("export type ")
|
|
||||||
) {
|
|
||||||
context = line
|
|
||||||
.trim()
|
|
||||||
.replace(/^\s*\/\/\s*/, "")
|
|
||||||
.replace(/^\s*\/\*\*\s*/, "");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry: TraceEntry = {
|
|
||||||
file: relativePath,
|
|
||||||
line: lineNum + 1,
|
|
||||||
context,
|
|
||||||
requirements: reqIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const reqId of reqIds) {
|
|
||||||
if (!requirementMap[reqId]) {
|
|
||||||
requirementMap[reqId] = [];
|
|
||||||
}
|
|
||||||
requirementMap[reqId].push(entry);
|
|
||||||
|
|
||||||
// Track by type
|
|
||||||
const type = reqId.substring(0, 2);
|
|
||||||
if (byType[type]) {
|
|
||||||
byType[type].add(reqId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalTraces++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Skip files we can't read
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
totalFiles: files.length,
|
|
||||||
totalTraces,
|
|
||||||
requirements: requirementMap,
|
|
||||||
byType: {
|
|
||||||
UR: Array.from(byType["UR"]).sort(),
|
|
||||||
IR: Array.from(byType["IR"]).sort(),
|
|
||||||
DR: Array.from(byType["DR"]).sort(),
|
|
||||||
JA: Array.from(byType["JA"]).sort(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMarkdown(data: TracesData): string {
|
|
||||||
let md = `# Code Traceability Matrix
|
|
||||||
|
|
||||||
**Generated:** ${new Date(data.timestamp).toLocaleString()}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- **Total Files Scanned:** ${data.totalFiles}
|
|
||||||
- **Total TRACES Found:** ${data.totalTraces}
|
|
||||||
- **Requirements Covered:**
|
|
||||||
- User Requirements (UR): ${data.byType.UR.length}
|
|
||||||
- Integration Requirements (IR): ${data.byType.IR.length}
|
|
||||||
- Development Requirements (DR): ${data.byType.DR.length}
|
|
||||||
- Jellyfin API Requirements (JA): ${data.byType.JA.length}
|
|
||||||
|
|
||||||
## Requirements by Type
|
|
||||||
|
|
||||||
### User Requirements (UR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.UR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Integration Requirements (IR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.IR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Development Requirements (DR)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.DR.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Jellyfin API Requirements (JA)
|
|
||||||
\`\`\`
|
|
||||||
${data.byType.JA.join(", ")}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
## Detailed Mapping
|
|
||||||
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Sort requirements by ID
|
|
||||||
const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
|
|
||||||
const typeA = a.substring(0, 2);
|
|
||||||
const typeB = b.substring(0, 2);
|
|
||||||
const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
|
|
||||||
if (typeOrder[typeA] !== typeOrder[typeB]) {
|
|
||||||
return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
|
|
||||||
}
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const reqId of sortedReqs) {
|
|
||||||
const entries = data.requirements[reqId];
|
|
||||||
md += `### ${reqId}\n\n`;
|
|
||||||
md += `**Locations:** ${entries.length} file(s)\n\n`;
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
|
|
||||||
md += ` - **Line:** ${entry.line}\n`;
|
|
||||||
const contextPreview = entry.context.substring(0, 70);
|
|
||||||
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
|
|
||||||
}
|
|
||||||
md += "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
return md;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateJson(data: TracesData): string {
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main
|
|
||||||
const args = Bun.argv.slice(2);
|
|
||||||
const format = args.includes("--format")
|
|
||||||
? args[args.indexOf("--format") + 1]
|
|
||||||
: "markdown";
|
|
||||||
|
|
||||||
console.error("🔍 Extracting TRACES from codebase...");
|
|
||||||
const data = extractTraces();
|
|
||||||
|
|
||||||
if (format === "json") {
|
|
||||||
console.log(generateJson(data));
|
|
||||||
} else {
|
|
||||||
console.log(generateMarkdown(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
|
||||||
);
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
//! Device identification commands
|
|
||||||
//!
|
|
||||||
//! Handles persistent device ID generation and retrieval for Jellyfin server communication.
|
|
||||||
//! TRACES: UR-009 | DR-011
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use log::info;
|
|
||||||
use tauri::State;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::commands::storage::DatabaseWrapper;
|
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|
||||||
|
|
||||||
/// Get or create the device ID.
|
|
||||||
/// Device ID is a UUID v4 that persists across app restarts.
|
|
||||||
/// On first call, generates and stores a new UUID.
|
|
||||||
/// On subsequent calls, retrieves the stored UUID.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// - `Ok(String)` - The device ID (UUID v4)
|
|
||||||
/// - `Err(String)` - If database operation fails
|
|
||||||
///
|
|
||||||
/// TRACES: UR-009 | DR-011
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
|
||||||
let db_service = {
|
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
Arc::new(database.service())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to get existing device ID from database
|
|
||||||
let query = Query::with_params(
|
|
||||||
"SELECT value FROM app_settings WHERE key = ?",
|
|
||||||
vec![QueryParam::String("device_id".to_string())],
|
|
||||||
);
|
|
||||||
|
|
||||||
let existing_id: Option<String> = db_service
|
|
||||||
.query_one(query, |row| row.get(0))
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
if let Some(device_id) = existing_id {
|
|
||||||
info!("[Device] Retrieved existing device ID");
|
|
||||||
return Ok(device_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new device ID
|
|
||||||
let device_id = Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
// Store it in database
|
|
||||||
let insert_query = Query::with_params(
|
|
||||||
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
|
|
||||||
vec![
|
|
||||||
QueryParam::String("device_id".to_string()),
|
|
||||||
QueryParam::String(device_id.clone()),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
db_service
|
|
||||||
.execute(insert_query)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
info!("[Device] Generated and stored new device ID");
|
|
||||||
Ok(device_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the device ID (primarily for testing or recovery).
|
|
||||||
/// Overwrites any existing device ID.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
/// * `device_id` - The device ID to store (should be UUID v4 format)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
/// - `Ok(())` - If device ID was stored successfully
|
|
||||||
/// - `Err(String)` - If database operation fails
|
|
||||||
///
|
|
||||||
/// TRACES: UR-009 | DR-011
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
|
||||||
let db_service = {
|
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
Arc::new(database.service())
|
|
||||||
};
|
|
||||||
|
|
||||||
let query = Query::with_params(
|
|
||||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
|
|
||||||
vec![
|
|
||||||
QueryParam::String("device_id".to_string()),
|
|
||||||
QueryParam::String(device_id),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
info!("[Device] Device ID set");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_device_id_is_valid_uuid() {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
// Should parse as UUID
|
|
||||||
let parsed = Uuid::parse_str(&id);
|
|
||||||
assert!(parsed.is_ok(), "Device ID should be a valid UUID");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_device_id_format() {
|
|
||||||
let id = Uuid::new_v4().to_string();
|
|
||||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars with hyphens)
|
|
||||||
assert_eq!(id.len(), 36, "Device ID should be 36 characters");
|
|
||||||
assert!(id.contains('-'), "Device ID should contain hyphens");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_device_ids_are_unique() {
|
|
||||||
let id1 = Uuid::new_v4().to_string();
|
|
||||||
let id2 = Uuid::new_v4().to_string();
|
|
||||||
assert_ne!(id1, id2, "Generated device IDs should be unique");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1530,7 +1530,6 @@ pub fn get_album_affinity_status(
|
|||||||
Ok(statuses)
|
Ok(statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-011, UR-018 | DR-015, DR-018 | UT-042, UT-043
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
// Tauri commands exposed to frontend
|
|
||||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
|
||||||
// DR-015, DR-017, DR-021, DR-028
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod connectivity;
|
pub mod connectivity;
|
||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod device;
|
|
||||||
pub mod download;
|
pub mod download;
|
||||||
pub mod offline;
|
pub mod offline;
|
||||||
pub mod playback_mode;
|
pub mod playback_mode;
|
||||||
@ -18,7 +14,6 @@ pub mod sync;
|
|||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use connectivity::*;
|
pub use connectivity::*;
|
||||||
pub use conversions::*;
|
pub use conversions::*;
|
||||||
pub use device::*;
|
|
||||||
pub use download::*;
|
pub use download::*;
|
||||||
pub use offline::*;
|
pub use offline::*;
|
||||||
pub use playback_mode::*;
|
pub use playback_mode::*;
|
||||||
|
|||||||
@ -130,7 +130,6 @@ pub async fn offline_search(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-002, UR-011 | DR-017 | UT-044
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -367,33 +367,6 @@ pub fn repository_get_image_url(
|
|||||||
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
|
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get subtitle URL for a media item
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn repository_get_subtitle_url(
|
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
|
||||||
handle: String,
|
|
||||||
item_id: String,
|
|
||||||
media_source_id: String,
|
|
||||||
stream_index: i32,
|
|
||||||
format: String,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
||||||
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get video download URL with quality preset
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn repository_get_video_download_url(
|
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
|
||||||
handle: String,
|
|
||||||
item_id: String,
|
|
||||||
quality: String,
|
|
||||||
media_source_id: Option<String>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
|
||||||
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark an item as favorite
|
/// Mark an item as favorite
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn repository_mark_favorite(
|
pub async fn repository_mark_favorite(
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
//! The sync queue stores mutations (favorites, playback progress, etc.)
|
||||||
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
//! that need to be synced to the Jellyfin server when connectivity is restored.
|
||||||
//! TRACES: UR-002, UR-017, UR-025 | DR-014
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@ -65,8 +65,6 @@ use commands::{
|
|||||||
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
|
auth_initialize, auth_connect_to_server, auth_login, auth_verify_session,
|
||||||
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
|
auth_logout, auth_get_session, auth_set_session, auth_start_verification,
|
||||||
auth_stop_verification, auth_reauthenticate,
|
auth_stop_verification, auth_reauthenticate,
|
||||||
// Device commands
|
|
||||||
device_get_id, device_set_id,
|
|
||||||
// Connectivity commands
|
// Connectivity commands
|
||||||
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
connectivity_check_server, connectivity_set_server_url, connectivity_get_status,
|
||||||
connectivity_start_monitoring, connectivity_stop_monitoring,
|
connectivity_start_monitoring, connectivity_stop_monitoring,
|
||||||
@ -644,9 +642,6 @@ pub fn run() {
|
|||||||
auth_start_verification,
|
auth_start_verification,
|
||||||
auth_stop_verification,
|
auth_stop_verification,
|
||||||
auth_reauthenticate,
|
auth_reauthenticate,
|
||||||
// Device commands
|
|
||||||
device_get_id,
|
|
||||||
device_set_id,
|
|
||||||
// Connectivity commands
|
// Connectivity commands
|
||||||
connectivity_check_server,
|
connectivity_check_server,
|
||||||
connectivity_set_server_url,
|
connectivity_set_server_url,
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Autoplay decision logic
|
|
||||||
// TRACES: UR-023, UR-026 | DR-047, DR-048, DR-029
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use crate::repository::types::MediaItem;
|
use crate::repository::types::MediaItem;
|
||||||
|
|
||||||
|
|||||||
@ -36,30 +36,40 @@ impl PlayerError {
|
|||||||
|
|
||||||
/// Player backend trait - implemented by platform-specific players
|
/// Player backend trait - implemented by platform-specific players
|
||||||
///
|
///
|
||||||
/// TRACES: UR-003, UR-004 | IR-003, IR-004 | DR-004
|
/// @req: UR-003 - Play videos
|
||||||
|
/// @req: UR-004 - Play audio uninterrupted
|
||||||
|
/// @req: IR-003 - Integration of libmpv for Linux playback
|
||||||
|
/// @req: IR-004 - Integration of ExoPlayer for Android playback
|
||||||
|
/// @req: DR-004 - PlayerBackend trait for platform-agnostic playback
|
||||||
pub trait PlayerBackend: Send + Sync {
|
pub trait PlayerBackend: Send + Sync {
|
||||||
/// Load a media item for playback
|
/// Load a media item for playback
|
||||||
/// TRACES: UR-005
|
///
|
||||||
|
/// @req: UR-005 - Control media playback (load operation)
|
||||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Start or resume playback
|
/// Start or resume playback
|
||||||
/// TRACES: UR-005
|
///
|
||||||
|
/// @req: UR-005 - Control media playback (play operation)
|
||||||
fn play(&mut self) -> Result<(), PlayerError>;
|
fn play(&mut self) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
/// TRACES: UR-005
|
///
|
||||||
|
/// @req: UR-005 - Control media playback (pause operation)
|
||||||
fn pause(&mut self) -> Result<(), PlayerError>;
|
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Stop playback and unload media
|
/// Stop playback and unload media
|
||||||
/// TRACES: UR-005
|
///
|
||||||
|
/// @req: UR-005 - Control media playback (stop operation)
|
||||||
fn stop(&mut self) -> Result<(), PlayerError>;
|
fn stop(&mut self) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Seek to a position in seconds
|
/// Seek to a position in seconds
|
||||||
/// TRACES: UR-005
|
///
|
||||||
|
/// @req: UR-005 - Control media playback (scrub operation)
|
||||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Set volume (0.0 - 1.0)
|
/// Set volume (0.0 - 1.0)
|
||||||
/// TRACES: UR-016
|
///
|
||||||
|
/// @req: UR-016 - Change system settings while playing (volume)
|
||||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||||
|
|
||||||
/// Get current playback position in seconds
|
/// Get current playback position in seconds
|
||||||
@ -232,13 +242,14 @@ impl PlayerBackend for NullBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-003, UR-004 | DR-004 | UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Test NullBackend volume default value
|
/// Test NullBackend volume default value
|
||||||
/// TRACES: UR-016 | DR-004 | UT-026
|
///
|
||||||
|
/// @req-test: UT-026 - NullBackend volume default value
|
||||||
|
/// @req-test: DR-004 - PlayerBackend trait
|
||||||
#[test]
|
#[test]
|
||||||
fn test_null_backend_volume_default() {
|
fn test_null_backend_volume_default() {
|
||||||
let backend = NullBackend::new();
|
let backend = NullBackend::new();
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
//!
|
//!
|
||||||
//! These events are emitted from the player backend to notify the frontend
|
//! These events are emitted from the player backend to notify the frontend
|
||||||
//! of playback state changes, position updates, etc.
|
//! of playback state changes, position updates, etc.
|
||||||
//!
|
|
||||||
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
|
||||||
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@ -16,8 +14,6 @@ use super::{MediaSessionType, SleepTimerMode};
|
|||||||
///
|
///
|
||||||
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
|
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
|
||||||
/// state machine transitions.
|
/// state machine transitions.
|
||||||
///
|
|
||||||
/// 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)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum PlayerStatusEvent {
|
pub enum PlayerStatusEvent {
|
||||||
|
|||||||
@ -38,8 +38,6 @@ 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
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MediaItem {
|
pub struct MediaItem {
|
||||||
@ -113,7 +111,6 @@ pub enum MediaType {
|
|||||||
Video,
|
Video,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: UR-002, UR-003, UR-004, UR-011 | DR-003
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum MediaSource {
|
pub enum MediaSource {
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
// Player module - Complete playback control system
|
|
||||||
// TRACES: UR-003, UR-004, UR-005, UR-019, UR-023, UR-026 |
|
|
||||||
// IR-003, IR-004, IR-006, IR-008 |
|
|
||||||
// DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
|
|
||||||
pub mod autoplay;
|
pub mod autoplay;
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
/// - Tokio runtime panics when spawning async tasks from std::thread
|
/// - Tokio runtime panics when spawning async tasks from std::thread
|
||||||
/// - Position update thread failures
|
/// - Position update thread failures
|
||||||
/// - Event emission issues
|
/// - Event emission issues
|
||||||
///
|
|
||||||
/// TRACES: UR-003, UR-004 | IR-003 | IT-003, IT-004
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@ -5,7 +5,8 @@ use super::media::{MediaItem, MediaSource, QueueContext};
|
|||||||
|
|
||||||
/// Repeat mode for the queue
|
/// Repeat mode for the queue
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-005
|
/// @req: UR-005 - Control media playback (repeat mode)
|
||||||
|
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RepeatMode {
|
pub enum RepeatMode {
|
||||||
@ -17,7 +18,10 @@ pub enum RepeatMode {
|
|||||||
|
|
||||||
/// Queue manager for playlist functionality
|
/// Queue manager for playlist functionality
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005, UR-015 | DR-005, DR-020
|
/// @req: UR-005 - Control media playback (queue navigation)
|
||||||
|
/// @req: UR-015 - View and manage current audio queue (add, reorder tracks)
|
||||||
|
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||||
|
/// @req: DR-020 - Queue management UI (add, remove, reorder)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct QueueManager {
|
pub struct QueueManager {
|
||||||
/// All items in the queue
|
/// All items in the queue
|
||||||
@ -505,7 +509,6 @@ pub enum AddPosition {
|
|||||||
End,
|
End,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005 | UT-003, UT-004, UT-005
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
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
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
pub enum SleepTimerMode {
|
pub enum SleepTimerMode {
|
||||||
@ -78,7 +77,6 @@ impl SleepTimerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-026 | DR-029 | UT-012
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -4,7 +4,8 @@ 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
|
/// @req: UR-005 - Control media playback (autoplay logic)
|
||||||
|
/// @req: DR-001 - Player state machine (end reason tracking)
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum EndReason {
|
pub enum EndReason {
|
||||||
@ -22,7 +23,8 @@ 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
|
/// @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
|
||||||
|
/// @req: UR-005 - Control media playback (state tracking)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum PlayerState {
|
pub enum PlayerState {
|
||||||
|
|||||||
@ -326,27 +326,6 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.get_image_url(item_id, image_type, options)
|
self.online.get_image_url(item_id, image_type, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: &str,
|
|
||||||
stream_index: i32,
|
|
||||||
format: &str,
|
|
||||||
) -> String {
|
|
||||||
// Always use online URL for subtitles
|
|
||||||
self.online.get_subtitle_url(item_id, media_source_id, stream_index, format)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
quality: &str,
|
|
||||||
media_source_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
// Always use online URL for downloads
|
|
||||||
self.online.get_video_download_url(item_id, quality, media_source_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
// Write operations go directly to server
|
// Write operations go directly to server
|
||||||
self.online.mark_favorite(item_id).await
|
self.online.mark_favorite(item_id).await
|
||||||
@ -518,25 +497,6 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_media_source_id: &str,
|
|
||||||
_stream_index: i32,
|
|
||||||
_format: &str,
|
|
||||||
) -> String {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_quality: &str,
|
|
||||||
_media_source_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@ -643,25 +603,6 @@ mod tests {
|
|||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_media_source_id: &str,
|
|
||||||
_stream_index: i32,
|
|
||||||
_format: &str,
|
|
||||||
) -> String {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_quality: &str,
|
|
||||||
_media_source_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
unimplemented!()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -146,23 +146,6 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
options: Option<ImageOptions>,
|
options: Option<ImageOptions>,
|
||||||
) -> String;
|
) -> String;
|
||||||
|
|
||||||
/// Get subtitle URL (synchronous - just constructs URL)
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: &str,
|
|
||||||
stream_index: i32,
|
|
||||||
format: &str,
|
|
||||||
) -> String;
|
|
||||||
|
|
||||||
/// Get video download URL (synchronous - just constructs URL)
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
quality: &str,
|
|
||||||
media_source_id: Option<&str>,
|
|
||||||
) -> String;
|
|
||||||
|
|
||||||
/// Mark item as favorite
|
/// Mark item as favorite
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError>;
|
||||||
|
|
||||||
|
|||||||
@ -918,27 +918,6 @@ impl MediaRepository for OfflineRepository {
|
|||||||
format!("offline://{}/{}", item_id, type_str)
|
format!("offline://{}/{}", item_id, type_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_media_source_id: &str,
|
|
||||||
_stream_index: i32,
|
|
||||||
_format: &str,
|
|
||||||
) -> String {
|
|
||||||
// Subtitles not available offline
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
_item_id: &str,
|
|
||||||
_quality: &str,
|
|
||||||
_media_source_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
// Cannot download while offline
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, _item_id: &str) -> Result<(), RepoError> {
|
||||||
// Cannot update server while offline
|
// Cannot update server while offline
|
||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
|
|||||||
@ -535,104 +535,18 @@ impl MediaRepository for OnlineRepository {
|
|||||||
&self,
|
&self,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_val = limit.unwrap_or(12);
|
let limit_str = limit.unwrap_or(16);
|
||||||
// Fetch more items to account for grouping reducing the count
|
|
||||||
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,People",
|
||||||
self.user_id, fetch_limit
|
self.user_id, limit_str
|
||||||
);
|
);
|
||||||
|
|
||||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||||
let items: Vec<MediaItem> = response
|
Ok(response
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| item.to_media_item(self.user_id.clone()))
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
||||||
.collect();
|
.collect())
|
||||||
|
|
||||||
debug!("[get_recently_played_audio] Fetched {} items", items.len());
|
|
||||||
for item in &items {
|
|
||||||
debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
|
|
||||||
item.name, item.item_type, item.album_id, item.album_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by album - create pseudo-album entries for tracks with same albumId
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
|
|
||||||
let mut ungrouped = Vec::new();
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
// Use album_id if available, fall back to album_name for grouping
|
|
||||||
let group_key = item.album_id.clone()
|
|
||||||
.or_else(|| item.album_name.clone());
|
|
||||||
|
|
||||||
if let Some(key) = group_key {
|
|
||||||
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
|
|
||||||
album_map.entry(key).or_insert_with(Vec::new).push(item);
|
|
||||||
} else {
|
|
||||||
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
|
|
||||||
ungrouped.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create album entries from grouped tracks
|
|
||||||
let mut result: Vec<MediaItem> = album_map
|
|
||||||
.into_iter()
|
|
||||||
.map(|(album_id, tracks)| {
|
|
||||||
let first_track = &tracks[0];
|
|
||||||
let most_recent = tracks.iter()
|
|
||||||
.max_by(|a, b| {
|
|
||||||
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
|
|
||||||
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
|
|
||||||
date_b.cmp(date_a)
|
|
||||||
})
|
|
||||||
.unwrap_or(first_track);
|
|
||||||
|
|
||||||
MediaItem {
|
|
||||||
id: album_id,
|
|
||||||
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
|
|
||||||
item_type: "MusicAlbum".to_string(),
|
|
||||||
server_id: first_track.server_id.clone(),
|
|
||||||
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: first_track.primary_image_tag.clone(),
|
|
||||||
backdrop_image_tags: None,
|
|
||||||
parent_backdrop_image_tags: None,
|
|
||||||
album_id: None,
|
|
||||||
album_name: None,
|
|
||||||
album_artist: None,
|
|
||||||
artists: first_track.artists.clone(),
|
|
||||||
artist_items: first_track.artist_items.clone(),
|
|
||||||
index_number: None,
|
|
||||||
parent_index_number: None,
|
|
||||||
series_id: None,
|
|
||||||
series_name: None,
|
|
||||||
season_id: None,
|
|
||||||
season_name: None,
|
|
||||||
user_data: most_recent.user_data.clone(),
|
|
||||||
media_streams: None,
|
|
||||||
media_sources: None,
|
|
||||||
people: None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Append ungrouped tracks
|
|
||||||
result.extend(ungrouped);
|
|
||||||
|
|
||||||
// Return only the requested limit
|
|
||||||
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
|
|
||||||
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
|
|
||||||
for item in &final_result {
|
|
||||||
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
|
|
||||||
}
|
|
||||||
Ok(final_result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@ -1029,50 +943,6 @@ impl MediaRepository for OnlineRepository {
|
|||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: &str,
|
|
||||||
stream_index: i32,
|
|
||||||
format: &str,
|
|
||||||
) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
|
||||||
self.server_url,
|
|
||||||
item_id,
|
|
||||||
media_source_id,
|
|
||||||
stream_index,
|
|
||||||
format
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
quality: &str,
|
|
||||||
media_source_id: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mut url = format!("{}/Videos/{}/download", self.server_url, item_id);
|
|
||||||
let mut params = vec![format!("api_key={}", self.access_token)];
|
|
||||||
|
|
||||||
// Add quality parameter if not "original"
|
|
||||||
if quality != "original" {
|
|
||||||
params.push(format!("quality={}", quality));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add media source ID if provided
|
|
||||||
if let Some(source_id) = media_source_id {
|
|
||||||
params.push(format!("mediaSourceId={}", source_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !params.is_empty() {
|
|
||||||
url.push('?');
|
|
||||||
url.push_str(¶ms.join("&"));
|
|
||||||
}
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||||
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
||||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||||
|
|||||||
@ -1,433 +0,0 @@
|
|||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::api::jellyfin::{
|
|
||||||
GetItemsOptions, ImageType, ImageOptions, SortOrder,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Mock for testing URL construction without a real server
|
|
||||||
struct MockOnlineRepository {
|
|
||||||
server_url: String,
|
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockOnlineRepository {
|
|
||||||
fn new(server_url: &str, access_token: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
server_url: server_url.to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct image URL similar to backend
|
|
||||||
fn get_image_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
image_type: &str,
|
|
||||||
options: Option<&ImageOptions>,
|
|
||||||
) -> String {
|
|
||||||
let mut url = format!(
|
|
||||||
"{}/Items/{}/Images/{}",
|
|
||||||
self.server_url, item_id, image_type
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut params = vec![("api_key", self.access_token.clone())];
|
|
||||||
|
|
||||||
if let Some(opts) = options {
|
|
||||||
if let Some(max_width) = opts.max_width {
|
|
||||||
params.push(("maxWidth", max_width.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(max_height) = opts.max_height {
|
|
||||||
params.push(("maxHeight", max_height.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(quality) = opts.quality {
|
|
||||||
params.push(("quality", quality.to_string()));
|
|
||||||
}
|
|
||||||
if let Some(tag) = &opts.tag {
|
|
||||||
params.push(("tag", tag.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query_string = params
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{}={}", k, v))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
if !query_string.is_empty() {
|
|
||||||
url.push('?');
|
|
||||||
url.push_str(&query_string);
|
|
||||||
}
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct subtitle URL
|
|
||||||
fn get_subtitle_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
media_source_id: &str,
|
|
||||||
stream_index: usize,
|
|
||||||
format: &str,
|
|
||||||
) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/Subtitles/{}/{}/subtitles.{}?api_key={}",
|
|
||||||
self.server_url,
|
|
||||||
item_id,
|
|
||||||
media_source_id,
|
|
||||||
stream_index,
|
|
||||||
format,
|
|
||||||
self.access_token
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test helper: construct video download URL
|
|
||||||
fn get_video_download_url(
|
|
||||||
&self,
|
|
||||||
item_id: &str,
|
|
||||||
quality: &str,
|
|
||||||
) -> String {
|
|
||||||
let (max_width, bitrate) = match quality {
|
|
||||||
"1080p" => ("1920", "15000k"),
|
|
||||||
"720p" => ("1280", "8000k"),
|
|
||||||
"480p" => ("854", "3000k"),
|
|
||||||
_ => ("0", ""), // original
|
|
||||||
};
|
|
||||||
|
|
||||||
if quality == "original" {
|
|
||||||
format!("{}/Videos/{}/stream.mp4?api_key={}", self.server_url, item_id, self.access_token)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}/Videos/{}/stream.mp4?maxWidth={}&videoBitrate={}&api_key={}",
|
|
||||||
self.server_url, item_id, max_width, bitrate, self.access_token
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Image URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_basic() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
assert!(url.contains("https://jellyfin.example.com"));
|
|
||||||
assert!(url.contains("/Items/item123/Images/Primary"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_with_max_width() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=300"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_with_all_options() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(1920),
|
|
||||||
max_height: Some(1080),
|
|
||||||
quality: Some(90),
|
|
||||||
tag: Some("abc123".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item456", "Backdrop", Some(&options));
|
|
||||||
|
|
||||||
assert!(url.contains("/Items/item456/Images/Backdrop"));
|
|
||||||
assert!(url.contains("maxWidth=1920"));
|
|
||||||
assert!(url.contains("maxHeight=1080"));
|
|
||||||
assert!(url.contains("quality=90"));
|
|
||||||
assert!(url.contains("tag=abc123"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_different_image_types() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let image_types = vec!["Primary", "Backdrop", "Logo", "Thumb"];
|
|
||||||
|
|
||||||
for image_type in image_types {
|
|
||||||
let url = repo.get_image_url("item123", image_type, None);
|
|
||||||
assert!(url.contains(&format!("/Images/{}", image_type)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_credentials_included_in_backend() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
// Credentials should be included in backend-generated URL
|
|
||||||
assert!(url.contains("api_key=secret_token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_proper_encoding() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: Some("tag-with-special-chars".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// URL should be properly formatted
|
|
||||||
assert!(url.contains("?"));
|
|
||||||
assert!(url.contains("&") || !url.contains("&&")); // No double ampersands
|
|
||||||
assert!(!url.ends_with("&")); // No trailing ampersand
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Subtitle URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_vtt_format() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", 0, "vtt");
|
|
||||||
|
|
||||||
assert!(url.contains("Videos/item123"));
|
|
||||||
assert!(url.contains("Subtitles/source456/0"));
|
|
||||||
assert!(url.contains("subtitles.vtt"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_srt_format() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", 1, "srt");
|
|
||||||
|
|
||||||
assert!(url.contains("Subtitles/source456/1"));
|
|
||||||
assert!(url.contains("subtitles.srt"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_multiple_streams() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
for stream_index in 0..5 {
|
|
||||||
let url = repo.get_subtitle_url("item123", "source456", stream_index, "vtt");
|
|
||||||
assert!(url.contains(&format!("/{}/subtitles", stream_index)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_subtitle_url_different_media_sources() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let media_sources = vec!["src1", "src2", "src3"];
|
|
||||||
|
|
||||||
for media_source_id in media_sources {
|
|
||||||
let url = repo.get_subtitle_url("item123", media_source_id, 0, "vtt");
|
|
||||||
assert!(url.contains(&format!("Subtitles/{}/", media_source_id)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Video Download URL Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_original_quality() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "original");
|
|
||||||
|
|
||||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
|
||||||
assert!(url.contains("api_key=token123"));
|
|
||||||
assert!(!url.contains("maxWidth")); // Original should have no transcoding params
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_1080p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "1080p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=1920"));
|
|
||||||
assert!(url.contains("videoBitrate=15000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_720p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "720p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=1280"));
|
|
||||||
assert!(url.contains("videoBitrate=8000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_480p() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let url = repo.get_video_download_url("item123", "480p");
|
|
||||||
|
|
||||||
assert!(url.contains("maxWidth=854"));
|
|
||||||
assert!(url.contains("videoBitrate=3000k"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_video_download_url_quality_presets() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
let qualities = vec!["original", "1080p", "720p", "480p"];
|
|
||||||
|
|
||||||
for quality in qualities {
|
|
||||||
let url = repo.get_video_download_url("item123", quality);
|
|
||||||
assert!(url.contains("Videos/item123/stream.mp4"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Security Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_credentials_never_exposed_in_frontend() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "super_secret_token");
|
|
||||||
|
|
||||||
let image_url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
|
|
||||||
let download_url = repo.get_video_download_url("item123", "720p");
|
|
||||||
|
|
||||||
// These URLs are constructed in BACKEND and returned to frontend
|
|
||||||
// Frontend never receives this token directly
|
|
||||||
assert!(image_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"));
|
|
||||||
|
|
||||||
// In actual implementation, frontend would only get the URL string
|
|
||||||
// Frontend cannot construct its own URLs or extract the token
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_url_parameter_injection_prevention() {
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
|
|
||||||
|
|
||||||
// Try to inject parameters through item_id
|
|
||||||
let malicious_id = "item123&extraParam=malicious";
|
|
||||||
let url = repo.get_image_url(malicious_id, "Primary", None);
|
|
||||||
|
|
||||||
// URL should contain the full item_id, backend should handle escaping
|
|
||||||
assert!(url.contains(malicious_id));
|
|
||||||
// Backend should be responsible for proper URL encoding
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== URL Format Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_image_url_format_correctness() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", None);
|
|
||||||
|
|
||||||
// Should be valid format
|
|
||||||
assert!(url.starts_with("https://server.com"));
|
|
||||||
assert!(url.contains("/Items/id123/Images/Primary"));
|
|
||||||
assert!(url.contains("?api_key="));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_query_string_properly_separated() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: Some(200),
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("id123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// Should have single ? separator
|
|
||||||
let question_marks = url.matches('?').count();
|
|
||||||
assert_eq!(question_marks, 1);
|
|
||||||
|
|
||||||
// Should have ampersands between params
|
|
||||||
assert!(url.contains("?"));
|
|
||||||
assert!(url.contains("&"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_special_characters_in_urls() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token_with_special-chars");
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
|
|
||||||
|
|
||||||
// Should handle special characters in token and id
|
|
||||||
assert!(url.contains("token_with_special-chars"));
|
|
||||||
assert!(url.contains("item-with-special_chars"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Backend vs Frontend Responsibility Tests =====
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_backend_owns_url_construction() {
|
|
||||||
// This test documents that URL construction is ONLY in backend
|
|
||||||
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
|
|
||||||
|
|
||||||
// Backend generates full URL with credentials
|
|
||||||
let url = repo.get_image_url("item123", "Primary", None);
|
|
||||||
|
|
||||||
// URL is complete and ready to use
|
|
||||||
assert!(url.starts_with("https://"));
|
|
||||||
assert!(url.contains("api_key="));
|
|
||||||
|
|
||||||
// Frontend never constructs URLs directly
|
|
||||||
// Frontend only receives pre-constructed URLs from backend
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_url_includes_all_necessary_parameters() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: Some(300),
|
|
||||||
max_height: Some(200),
|
|
||||||
quality: Some(90),
|
|
||||||
tag: Some("abc".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// All provided options should be in URL
|
|
||||||
assert!(url.contains("maxWidth=300"));
|
|
||||||
assert!(url.contains("maxHeight=200"));
|
|
||||||
assert!(url.contains("quality=90"));
|
|
||||||
assert!(url.contains("tag=abc"));
|
|
||||||
assert!(url.contains("api_key=token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_optional_parameters_omitted_when_not_provided() {
|
|
||||||
let repo = MockOnlineRepository::new("https://server.com", "token");
|
|
||||||
let options = ImageOptions {
|
|
||||||
max_width: None,
|
|
||||||
max_height: None,
|
|
||||||
quality: None,
|
|
||||||
tag: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = repo.get_image_url("item123", "Primary", Some(&options));
|
|
||||||
|
|
||||||
// Should only have api_key
|
|
||||||
assert!(url.contains("api_key=token"));
|
|
||||||
assert!(!url.contains("maxWidth"));
|
|
||||||
assert!(!url.contains("maxHeight"));
|
|
||||||
assert!(!url.contains("quality"));
|
|
||||||
assert!(!url.contains("tag"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -285,7 +285,6 @@ fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-002, UR-012 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -156,7 +156,6 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-002, UR-012, UR-019, UR-025 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -17,7 +17,6 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
("012_download_source", MIGRATION_012),
|
("012_download_source", MIGRATION_012),
|
||||||
("013_downloads_item_status_index", MIGRATION_013),
|
("013_downloads_item_status_index", MIGRATION_013),
|
||||||
("014_series_audio_preferences", MIGRATION_014),
|
("014_series_audio_preferences", MIGRATION_014),
|
||||||
("015_device_id", MIGRATION_015),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Initial schema migration
|
/// Initial schema migration
|
||||||
@ -639,20 +638,3 @@ CREATE TABLE IF NOT EXISTS series_audio_preferences (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
|
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
|
||||||
ON series_audio_preferences(user_id, series_id);
|
ON series_audio_preferences(user_id, series_id);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Migration to add device ID storage
|
|
||||||
/// - Creates app_settings table for app-wide configuration (device ID, etc.)
|
|
||||||
/// - Device ID is generated once and persisted for Jellyfin server identification
|
|
||||||
const MIGRATION_015: &str = r#"
|
|
||||||
-- App-wide settings table for device ID and other app-level configuration
|
|
||||||
-- Device ID is a unique identifier for this app installation
|
|
||||||
-- Required for Jellyfin server communication and session tracking
|
|
||||||
CREATE TABLE IF NOT EXISTS app_settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create index for efficient lookups (though key is already primary key)
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
|
|
||||||
"#;
|
|
||||||
|
|||||||
@ -1,545 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { RepositoryClient } from "./repository-client";
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests documenting Phase 1 & 2 refactoring:
|
|
||||||
* - Sorting moved to backend (no frontend compareFn)
|
|
||||||
* - Filtering moved to backend (no frontend iteration/matching)
|
|
||||||
* - URL construction moved to backend (async Tauri invoke)
|
|
||||||
* - Search moved to backend (backend search command)
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe("Backend Integration - Refactored Business Logic", () => {
|
|
||||||
let client: RepositoryClient;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
client = new RepositoryClient();
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Sorting Delegated to Backend", () => {
|
|
||||||
it("should pass sortBy to backend instead of frontend sorting", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [
|
|
||||||
{ id: "item1", name: "Album A" },
|
|
||||||
{ id: "item2", name: "Album B" },
|
|
||||||
{ id: "item3", name: "Album C" },
|
|
||||||
],
|
|
||||||
totalRecordCount: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backend should have done the sorting
|
|
||||||
expect(result.items[0].name).toBe("Album A");
|
|
||||||
|
|
||||||
// Frontend doesn't have a compareFn
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
parentId: "library123",
|
|
||||||
options: {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support different sort fields via backend", async () => {
|
|
||||||
const sortFields = ["SortName", "Artist", "Album", "DatePlayed", "ProductionYear"];
|
|
||||||
|
|
||||||
for (const sortField of sortFields) {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.getItems("library123", {
|
|
||||||
sortBy: sortField,
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_items",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
sortBy: sortField,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass sort order to backend", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Descending",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_items",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
sortOrder: "Descending",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT include frontend compareFn (removed entirely)", async () => {
|
|
||||||
// Old code pattern:
|
|
||||||
// sortOptions: [{
|
|
||||||
// key: "title",
|
|
||||||
// label: "Title",
|
|
||||||
// compareFn: (a, b) => a.name.localeCompare(b.name) // ← REMOVED
|
|
||||||
// }]
|
|
||||||
|
|
||||||
// New code pattern:
|
|
||||||
// sortOptions: [{
|
|
||||||
// key: "SortName", // Jellyfin field name
|
|
||||||
// label: "Title"
|
|
||||||
// }]
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
{ key: "Artist", label: "Artist" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify no compareFn property exists
|
|
||||||
for (const option of config.sortOptions) {
|
|
||||||
expect((option as any).compareFn).toBeUndefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Filtering Delegated to Backend", () => {
|
|
||||||
it("should pass includeItemTypes to backend", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.getItems("library123", {
|
|
||||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_items",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
includeItemTypes: ["Audio", "MusicAlbum"],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass genres filter to backend", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.getItems("library123", {
|
|
||||||
genres: ["Rock", "Jazz"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_items",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
genres: ["Rock", "Jazz"],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT include frontend filtering logic", async () => {
|
|
||||||
// Old code pattern:
|
|
||||||
// let filtered = items.filter(item => {
|
|
||||||
// return searchFields.some(field => {
|
|
||||||
// const fieldValue = item[field]?.toLowerCase() ?? "";
|
|
||||||
// return fieldValue.includes(query.toLowerCase());
|
|
||||||
// });
|
|
||||||
// }); // ← REMOVED
|
|
||||||
|
|
||||||
// New code pattern:
|
|
||||||
// Use backend search instead
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [{ id: "item1", name: "Search Result" }],
|
|
||||||
totalRecordCount: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.search("query");
|
|
||||||
|
|
||||||
expect(result.items.length).toBeGreaterThan(0);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_search",
|
|
||||||
expect.objectContaining({
|
|
||||||
query: "query",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support pagination via backend", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.getItems("library123", {
|
|
||||||
startIndex: 100,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_items",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
startIndex: 100,
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Search Delegated to Backend", () => {
|
|
||||||
it("should use backend search command instead of frontend filtering", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [
|
|
||||||
{ id: "item1", name: "Found Item" },
|
|
||||||
{ id: "item2", name: "Another Found Item" },
|
|
||||||
],
|
|
||||||
totalRecordCount: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.search("query");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_search",
|
|
||||||
expect.objectContaining({
|
|
||||||
query: "query",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.items.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support search with item type filters", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.search("query", {
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_search",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: expect.objectContaining({
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT do client-side search filtering", async () => {
|
|
||||||
// Old code pattern:
|
|
||||||
// const query = searchInput.toLowerCase();
|
|
||||||
// const results = items.filter(item =>
|
|
||||||
// config.searchFields.some(field =>
|
|
||||||
// item[field]?.toLowerCase()?.includes(query)
|
|
||||||
// )
|
|
||||||
// ); // ← REMOVED
|
|
||||||
|
|
||||||
// New code pattern:
|
|
||||||
// Call backend search directly
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [{ id: "item1" }],
|
|
||||||
totalRecordCount: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.search("search term");
|
|
||||||
|
|
||||||
// Backend did the filtering
|
|
||||||
expect(result.items).toBeDefined();
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_search", expect.any(Object));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("URL Construction Delegated to Backend", () => {
|
|
||||||
it("should get image URLs from backend (not construct in frontend)", async () => {
|
|
||||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
|
||||||
|
|
||||||
const url = await client.getImageUrl("item123", "Primary");
|
|
||||||
|
|
||||||
// Backend constructed and returned the URL
|
|
||||||
expect(url).toBe(backendUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_image_url",
|
|
||||||
expect.objectContaining({
|
|
||||||
itemId: "item123",
|
|
||||||
imageType: "Primary",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT construct image URLs in frontend", async () => {
|
|
||||||
// Old code pattern:
|
|
||||||
// return `${serverUrl}/Items/${itemId}/Images/${imageType}?api_key=${token}&maxWidth=${options.maxWidth}`;
|
|
||||||
// ← REMOVED - NEVER construct URLs in frontend
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce("https://server.com/image");
|
|
||||||
|
|
||||||
const url = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
|
||||||
|
|
||||||
// URL came from backend, not constructed in frontend
|
|
||||||
expect(typeof url).toBe("string");
|
|
||||||
expect(url).toContain("http");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get video stream URLs from backend", async () => {
|
|
||||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
|
||||||
|
|
||||||
const url = await client.getVideoStreamUrl("item123");
|
|
||||||
|
|
||||||
expect(url).toBe(backendUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_video_stream_url",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get subtitle URLs from backend", async () => {
|
|
||||||
const backendUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
|
||||||
|
|
||||||
const url = await client.getSubtitleUrl("item123", "source456", 0);
|
|
||||||
|
|
||||||
expect(url).toBe(backendUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_subtitle_url",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get video download URLs from backend", async () => {
|
|
||||||
const backendUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1280&api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(backendUrl);
|
|
||||||
|
|
||||||
const url = await client.getVideoDownloadUrl("item123", "720p");
|
|
||||||
|
|
||||||
expect(url).toBe(backendUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_video_download_url",
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should never expose access token in frontend code", async () => {
|
|
||||||
// The access token is NEVER used in frontend URL construction
|
|
||||||
// It's only stored in backend for secure URL generation
|
|
||||||
|
|
||||||
// Frontend code NEVER has direct access to use the token
|
|
||||||
const client2 = new RepositoryClient();
|
|
||||||
// client2._accessToken is private and should never be accessed or used
|
|
||||||
|
|
||||||
// All token usage is in backend via Tauri commands
|
|
||||||
expect(invoke).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Component Config Simplification", () => {
|
|
||||||
it("should have simplified MediaListConfig (no searchFields)", () => {
|
|
||||||
// Old type:
|
|
||||||
// interface MediaListConfig {
|
|
||||||
// searchFields: string[]; // ← REMOVED
|
|
||||||
// compareFn?: (a, b) => number; // ← REMOVED
|
|
||||||
// }
|
|
||||||
|
|
||||||
// New type:
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
// No compareFn
|
|
||||||
],
|
|
||||||
// No searchFields
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify no searchFields
|
|
||||||
expect((config as any).searchFields).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use Jellyfin field names in sort options", () => {
|
|
||||||
// Old:
|
|
||||||
// { key: "title", label: "Title", compareFn: ... }
|
|
||||||
|
|
||||||
// New:
|
|
||||||
// { key: "SortName", label: "Title" }
|
|
||||||
|
|
||||||
const sortOptions = [
|
|
||||||
{ key: "SortName", label: "A-Z" },
|
|
||||||
{ key: "Artist", label: "Artist" },
|
|
||||||
{ key: "Album", label: "Album" },
|
|
||||||
{ key: "DatePlayed", label: "Recent" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const option of sortOptions) {
|
|
||||||
// Should be Jellyfin field names
|
|
||||||
expect(typeof option.key).toBe("string");
|
|
||||||
expect(option.key).toMatch(/^[A-Z]/); // Jellyfin fields start with capital
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Debounced Search Implementation", () => {
|
|
||||||
it("should debounce search without frontend filtering", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const mockSearch = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
(invoke as any).mockImplementation((cmd: string, args: any) => {
|
|
||||||
if (cmd === "repository_search") {
|
|
||||||
return mockSearch(args.query);
|
|
||||||
}
|
|
||||||
return Promise.resolve({ items: [], totalRecordCount: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate rapid search queries
|
|
||||||
await client.search("t");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
await client.search("te");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
await client.search("test");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// All calls go to backend (debouncing happens in component via $effect)
|
|
||||||
expect(invoke).toHaveBeenCalled();
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("End-to-End Data Flow", () => {
|
|
||||||
it("should support complete flow: load → sort → display", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [
|
|
||||||
{ id: "id1", name: "Album A", sortName: "A" },
|
|
||||||
{ id: "id2", name: "Album B", sortName: "B" },
|
|
||||||
],
|
|
||||||
totalRecordCount: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frontend requests items with sort
|
|
||||||
const result = await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backend returned pre-sorted items
|
|
||||||
expect(result.items[0].sortName).toBe("A");
|
|
||||||
expect(result.items[1].sortName).toBe("B");
|
|
||||||
|
|
||||||
// Frontend just displays them
|
|
||||||
// No compareFn, no local sorting
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support complete flow: search → load images → display", async () => {
|
|
||||||
// 1. Frontend calls backend search
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [{ id: "item1", name: "Result", primaryImageTag: "tag1" }],
|
|
||||||
totalRecordCount: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchResult = await client.search("query");
|
|
||||||
expect(searchResult.items.length).toBe(1);
|
|
||||||
|
|
||||||
// 2. Frontend loads image URL from backend
|
|
||||||
(invoke as any).mockResolvedValueOnce("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
const imageUrl = await client.getImageUrl("item1", "Primary");
|
|
||||||
expect(imageUrl).toContain("http");
|
|
||||||
|
|
||||||
// 3. Frontend displays search results with images
|
|
||||||
// No client-side filtering, sorting, or URL construction
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Performance Characteristics", () => {
|
|
||||||
it("should reduce memory usage by not storing frontend sorting state", async () => {
|
|
||||||
// Old: Frontend stores items + sorting state + filtered results
|
|
||||||
// Old: Multiple copies of data (original, filtered, sorted)
|
|
||||||
|
|
||||||
// New: Backend returns already-sorted data
|
|
||||||
// New: Frontend just stores the result
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: Array.from({ length: 10000 }, (_, i) => ({
|
|
||||||
id: `id${i}`,
|
|
||||||
name: `Item ${i}`,
|
|
||||||
})),
|
|
||||||
totalRecordCount: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
limit: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backend handled sorting
|
|
||||||
expect(result.items.length).toBe(10000);
|
|
||||||
// Frontend just stores the result array
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reduce CPU usage by avoiding client-side operations", async () => {
|
|
||||||
// Old pattern required:
|
|
||||||
// - Parsing all items into memory
|
|
||||||
// - Iterating to apply filters
|
|
||||||
// - Sorting algorithm (O(n log n) comparisons)
|
|
||||||
// - Updating multiple state variables
|
|
||||||
|
|
||||||
// New pattern:
|
|
||||||
(invoke as any).mockResolvedValueOnce({
|
|
||||||
items: [], // Backend already filtered/sorted
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Frontend just awaits backend result
|
|
||||||
const result = await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
});
|
|
||||||
|
|
||||||
// No client-side work
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,428 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { RepositoryClient } from "./repository-client";
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core");
|
|
||||||
|
|
||||||
describe("RepositoryClient", () => {
|
|
||||||
let client: RepositoryClient;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new RepositoryClient();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Initialization", () => {
|
|
||||||
it("should initialize with no handle", () => {
|
|
||||||
expect(() => client.getHandle()).toThrow("Repository not initialized");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create repository with invoke command", async () => {
|
|
||||||
const mockHandle = "test-handle-123";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
|
||||||
|
|
||||||
const handle = await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
expect(handle).toBe(mockHandle);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_create", {
|
|
||||||
serverUrl: "https://server.com",
|
|
||||||
userId: "user1",
|
|
||||||
accessToken: "token123",
|
|
||||||
serverId: "server1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should store handle after creation", async () => {
|
|
||||||
const mockHandle = "test-handle-456";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
|
||||||
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
expect(client.getHandle()).toBe(mockHandle);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should destroy repository and clear handle", async () => {
|
|
||||||
const mockHandle = "test-handle-789";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockHandle);
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce(undefined);
|
|
||||||
await client.destroy();
|
|
||||||
|
|
||||||
expect(() => client.getHandle()).toThrow("Repository not initialized");
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_destroy", { handle: mockHandle });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Image URL Methods", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get image URL from backend", async () => {
|
|
||||||
const mockUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const imageUrl = await client.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
|
||||||
|
|
||||||
expect(imageUrl).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
imageType: "Primary",
|
|
||||||
options: { maxWidth: 300 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use default image type if not provided", async () => {
|
|
||||||
const mockUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getImageUrl("item123");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
imageType: "Primary",
|
|
||||||
options: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass multiple image options to backend", async () => {
|
|
||||||
const mockUrl = "https://server.com/Items/item123/Images/Backdrop?maxWidth=1920&maxHeight=1080&quality=90&api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
maxWidth: 1920,
|
|
||||||
maxHeight: 1080,
|
|
||||||
quality: 90,
|
|
||||||
tag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
await client.getImageUrl("item123", "Backdrop", options);
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
imageType: "Backdrop",
|
|
||||||
options,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different image types", async () => {
|
|
||||||
const mockUrl = "https://server.com/Items/item123/Images/Logo?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getImageUrl("item123", "Logo");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_image_url", {
|
|
||||||
handle: expect.any(String),
|
|
||||||
itemId: "item123",
|
|
||||||
imageType: "Logo",
|
|
||||||
options: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error if not initialized before getImageUrl", async () => {
|
|
||||||
const newClient = new RepositoryClient();
|
|
||||||
|
|
||||||
await expect(newClient.getImageUrl("item123")).rejects.toThrow(
|
|
||||||
"Repository not initialized"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Subtitle URL Methods", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get subtitle URL from backend", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/1/subtitles.vtt?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const subtitleUrl = await client.getSubtitleUrl("item123", "source456", 0);
|
|
||||||
|
|
||||||
expect(subtitleUrl).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
mediaSourceId: "source456",
|
|
||||||
streamIndex: 0,
|
|
||||||
format: "vtt",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use default format if not provided", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.vtt?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getSubtitleUrl("item123", "source456", 0);
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
|
||||||
handle: expect.any(String),
|
|
||||||
itemId: "item123",
|
|
||||||
mediaSourceId: "source456",
|
|
||||||
streamIndex: 0,
|
|
||||||
format: "vtt",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support custom subtitle formats", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/Subtitles/0/subtitles.srt?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getSubtitleUrl("item123", "source456", 1, "srt");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_subtitle_url", {
|
|
||||||
handle: expect.any(String),
|
|
||||||
itemId: "item123",
|
|
||||||
mediaSourceId: "source456",
|
|
||||||
streamIndex: 1,
|
|
||||||
format: "srt",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Video Download URL Methods", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get video download URL from backend", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?maxWidth=1920&api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const downloadUrl = await client.getVideoDownloadUrl("item123", "1080p");
|
|
||||||
|
|
||||||
expect(downloadUrl).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
quality: "1080p",
|
|
||||||
mediaSourceId: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use original quality by default", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getVideoDownloadUrl("item123");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
|
||||||
handle: expect.any(String),
|
|
||||||
itemId: "item123",
|
|
||||||
quality: "original",
|
|
||||||
mediaSourceId: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support quality presets", async () => {
|
|
||||||
const qualities = ["original", "1080p", "720p", "480p"];
|
|
||||||
|
|
||||||
for (const quality of qualities) {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce(`https://server.com/stream.mp4?quality=${quality}`);
|
|
||||||
|
|
||||||
await client.getVideoDownloadUrl("item123", quality as any);
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_video_download_url",
|
|
||||||
expect.objectContaining({
|
|
||||||
quality,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support optional media source ID", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
await client.getVideoDownloadUrl("item123", "720p", "source789");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_download_url", {
|
|
||||||
handle: expect.any(String),
|
|
||||||
itemId: "item123",
|
|
||||||
quality: "720p",
|
|
||||||
mediaSourceId: "source789",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Library Methods", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get libraries from backend", async () => {
|
|
||||||
const mockLibraries = [
|
|
||||||
{ id: "lib1", name: "Music", collectionType: "music" },
|
|
||||||
{ id: "lib2", name: "Movies", collectionType: "movies" },
|
|
||||||
];
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockLibraries);
|
|
||||||
|
|
||||||
const libraries = await client.getLibraries();
|
|
||||||
|
|
||||||
expect(libraries).toEqual(mockLibraries);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_libraries", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get items with sorting parameters", async () => {
|
|
||||||
const mockResult = {
|
|
||||||
items: [
|
|
||||||
{ id: "item1", name: "Track 1", type: "Audio" },
|
|
||||||
{ id: "item2", name: "Track 2", type: "Audio" },
|
|
||||||
],
|
|
||||||
totalRecordCount: 2,
|
|
||||||
};
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
|
||||||
|
|
||||||
const result = await client.getItems("library123", {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(mockResult);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_items", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
parentId: "library123",
|
|
||||||
options: {
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
limit: 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should search with backend search command", async () => {
|
|
||||||
const mockResult = {
|
|
||||||
items: [
|
|
||||||
{ id: "item1", name: "Search Result 1", type: "Audio" },
|
|
||||||
],
|
|
||||||
totalRecordCount: 1,
|
|
||||||
};
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
|
||||||
|
|
||||||
const result = await client.search("query", {
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(mockResult);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_search", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
query: "query",
|
|
||||||
options: {
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Playback Methods", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get audio stream URL", async () => {
|
|
||||||
const mockUrl = "https://server.com/Audio/item123/stream.mp3?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const url = await client.getAudioStreamUrl("item123");
|
|
||||||
|
|
||||||
expect(url).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_audio_stream_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get video stream URL", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const url = await client.getVideoStreamUrl("item123");
|
|
||||||
|
|
||||||
expect(url).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
mediaSourceId: null,
|
|
||||||
startTimeSeconds: null,
|
|
||||||
audioStreamIndex: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should get video stream URL with options", async () => {
|
|
||||||
const mockUrl = "https://server.com/Videos/item123/stream.mp4?start=300&api_key=token";
|
|
||||||
(invoke as any).mockResolvedValueOnce(mockUrl);
|
|
||||||
|
|
||||||
const url = await client.getVideoStreamUrl("item123", "source456", 300, 0);
|
|
||||||
|
|
||||||
expect(url).toBe(mockUrl);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_get_video_stream_url", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
mediaSourceId: "source456",
|
|
||||||
startTimeSeconds: 300,
|
|
||||||
audioStreamIndex: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should report playback progress", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce(undefined);
|
|
||||||
|
|
||||||
await client.reportPlaybackProgress("item123", 5000000);
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
|
||||||
handle: "test-handle-123",
|
|
||||||
itemId: "item123",
|
|
||||||
positionTicks: 5000000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Handling", () => {
|
|
||||||
it("should throw error if invoke fails", async () => {
|
|
||||||
(invoke as any).mockRejectedValueOnce(new Error("Network error"));
|
|
||||||
|
|
||||||
await expect(client.create("https://server.com", "user1", "token", "server1")).rejects.toThrow(
|
|
||||||
"Network error"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle missing optional parameters", async () => {
|
|
||||||
(invoke as any).mockResolvedValueOnce("test-handle-123");
|
|
||||||
await client.create("https://server.com", "user1", "token123", "server1");
|
|
||||||
|
|
||||||
(invoke as any).mockResolvedValueOnce("");
|
|
||||||
|
|
||||||
await client.getImageUrl("item123");
|
|
||||||
|
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
|
||||||
"repository_get_image_url",
|
|
||||||
expect.objectContaining({
|
|
||||||
options: null,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -215,53 +215,83 @@ export class RepositoryClient {
|
|||||||
// ===== URL Construction Methods (sync, no server call) =====
|
// ===== URL Construction Methods (sync, no server call) =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image URL from backend
|
* Get image URL - constructs URL synchronously (no server call)
|
||||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
|
||||||
*/
|
*/
|
||||||
async getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): Promise<string> {
|
getImageUrl(itemId: string, imageType: ImageType = "Primary", options?: ImageOptions): string {
|
||||||
return invoke<string>("repository_get_image_url", {
|
if (!this._serverUrl || !this._accessToken) {
|
||||||
handle: this.ensureHandle(),
|
throw new Error("Repository not initialized - call create() first");
|
||||||
itemId,
|
}
|
||||||
imageType,
|
|
||||||
options: options ?? null,
|
let url = `${this._serverUrl}/Items/${itemId}/Images/${imageType}`;
|
||||||
});
|
const params: string[] = [`api_key=${this._accessToken}`];
|
||||||
|
|
||||||
|
if (options) {
|
||||||
|
if (options.maxWidth) params.push(`maxWidth=${options.maxWidth}`);
|
||||||
|
if (options.maxHeight) params.push(`maxHeight=${options.maxHeight}`);
|
||||||
|
if (options.quality) params.push(`quality=${options.quality}`);
|
||||||
|
if (options.tag) params.push(`tag=${options.tag}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${url}?${params.join('&')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subtitle URL from backend
|
* Get subtitle URL - constructs URL synchronously (no server call)
|
||||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
|
||||||
*/
|
*/
|
||||||
async getSubtitleUrl(
|
getSubtitleUrl(itemId: string, mediaSourceId: string, streamIndex: number, format: string = "vtt"): string {
|
||||||
itemId: string,
|
if (!this._serverUrl || !this._accessToken) {
|
||||||
mediaSourceId: string,
|
throw new Error("Repository not initialized - call create() first");
|
||||||
streamIndex: number,
|
}
|
||||||
format: string = "vtt"
|
return `${this._serverUrl}/Videos/${itemId}/${mediaSourceId}/Subtitles/${streamIndex}/Stream.${format}?api_key=${this._accessToken}`;
|
||||||
): Promise<string> {
|
|
||||||
return invoke<string>("repository_get_subtitle_url", {
|
|
||||||
handle: this.ensureHandle(),
|
|
||||||
itemId,
|
|
||||||
mediaSourceId,
|
|
||||||
streamIndex,
|
|
||||||
format,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get video download URL with quality preset from backend
|
* Get video download URL with quality preset - constructs URL synchronously
|
||||||
* The Rust backend constructs and returns the URL with proper credentials handling
|
* Used for offline downloads
|
||||||
* Used for offline downloads and transcoding
|
|
||||||
*/
|
*/
|
||||||
async getVideoDownloadUrl(
|
getVideoDownloadUrl(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
quality: QualityPreset = "original",
|
quality: QualityPreset = "original",
|
||||||
mediaSourceId?: string
|
mediaSourceId?: string
|
||||||
): Promise<string> {
|
): string {
|
||||||
return invoke<string>("repository_get_video_download_url", {
|
if (!this._serverUrl || !this._accessToken) {
|
||||||
handle: this.ensureHandle(),
|
throw new Error("Repository not initialized - call create() first");
|
||||||
itemId,
|
}
|
||||||
quality,
|
|
||||||
mediaSourceId: mediaSourceId ?? null,
|
const preset = QUALITY_PRESETS[quality];
|
||||||
|
|
||||||
|
if (quality === "original" || !preset.videoBitrate) {
|
||||||
|
// Direct stream for original quality
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_key: this._accessToken,
|
||||||
|
Static: "true",
|
||||||
|
audioStreamIndex: "0",
|
||||||
});
|
});
|
||||||
|
if (mediaSourceId) {
|
||||||
|
params.append("MediaSourceId", mediaSourceId);
|
||||||
|
}
|
||||||
|
return `${this._serverUrl}/Videos/${itemId}/stream?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcoded download with quality preset
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
api_key: this._accessToken,
|
||||||
|
DeviceId: localStorage.getItem("jellytau_device_id") || "jellytau",
|
||||||
|
Container: "mp4",
|
||||||
|
VideoCodec: "h264",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
AudioStreamIndex: "0",
|
||||||
|
VideoBitrate: preset.videoBitrate.toString(),
|
||||||
|
AudioBitrate: preset.audioBitrate.toString(),
|
||||||
|
MaxHeight: preset.maxHeight?.toString() ?? "",
|
||||||
|
TranscodingMaxAudioChannels: "2",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mediaSourceId) {
|
||||||
|
params.append("MediaSourceId", mediaSourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this._serverUrl}/Videos/${itemId}/stream.mp4?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Favorite Methods (via Rust) =====
|
// ===== Favorite Methods (via Rust) =====
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
let currentIndex = $state(0);
|
let currentIndex = $state(0);
|
||||||
let intervalId: number | null = null;
|
let intervalId: number | null = null;
|
||||||
let heroImageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Touch/swipe state
|
// Touch/swipe state
|
||||||
let touchStartX = $state(0);
|
let touchStartX = $state(0);
|
||||||
@ -22,81 +21,65 @@
|
|||||||
|
|
||||||
const currentItem = $derived(items[currentIndex] ?? null);
|
const currentItem = $derived(items[currentIndex] ?? null);
|
||||||
|
|
||||||
// Load hero image URL asynchronously based on item priority
|
function getHeroImageUrl(): string {
|
||||||
async function loadHeroImageUrl(): Promise<void> {
|
if (!currentItem) return "";
|
||||||
if (!currentItem) {
|
|
||||||
heroImageUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
// 1. Try backdrop image first (best for hero display)
|
// 1. Try backdrop image first (best for hero display)
|
||||||
if (currentItem.backdropImageTags?.[0]) {
|
if (currentItem.backdropImageTags?.[0]) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Backdrop", {
|
return repo.getImageUrl(currentItem.id, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: currentItem.backdropImageTags[0],
|
tag: currentItem.backdropImageTags[0],
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. For episodes, try to use series backdrop from parent
|
// 2. For episodes, try to use series backdrop from parent
|
||||||
if (currentItem.type === "Episode") {
|
if (currentItem.type === "Episode") {
|
||||||
// First try parent backdrop tags (includes image tag for caching)
|
// First try parent backdrop tags (includes image tag for caching)
|
||||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: currentItem.parentBackdropImageTags[0],
|
tag: currentItem.parentBackdropImageTags[0],
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Fallback: try series backdrop without tag (may not be cached optimally)
|
// Fallback: try series backdrop without tag (may not be cached optimally)
|
||||||
if (currentItem.seriesId) {
|
if (currentItem.seriesId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
return repo.getImageUrl(currentItem.seriesId, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Last resort for episodes: try season backdrop
|
// Last resort for episodes: try season backdrop
|
||||||
if (currentItem.seasonId) {
|
if (currentItem.seasonId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
return repo.getImageUrl(currentItem.seasonId, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. For music tracks, try album backdrop first, then primary
|
// 3. For music tracks, try album backdrop first, then primary
|
||||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||||
// Try album backdrop first (more cinematic for hero)
|
// Try album backdrop first (more cinematic for hero)
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
return repo.getImageUrl(currentItem.albumId, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
// 4. Fall back to primary image (poster, album art, episode thumbnail)
|
||||||
if (currentItem.primaryImageTag) {
|
if (currentItem.primaryImageTag) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.id, "Primary", {
|
return repo.getImageUrl(currentItem.id, "Primary", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: currentItem.primaryImageTag,
|
tag: currentItem.primaryImageTag,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Last resort for audio: try album primary image
|
// 5. Last resort for audio: try album primary image
|
||||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||||
heroImageUrl = await repo.getImageUrl(currentItem.albumId, "Primary", {
|
return repo.getImageUrl(currentItem.albumId, "Primary", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heroImageUrl = "";
|
return "";
|
||||||
} catch {
|
|
||||||
heroImageUrl = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
@ -143,11 +126,6 @@
|
|||||||
touchEndX = 0;
|
touchEndX = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load hero image whenever current item changes
|
|
||||||
$effect(() => {
|
|
||||||
loadHeroImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-rotate logic
|
// Auto-rotate logic
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoRotate && items.length > 1) {
|
if (autoRotate && items.length > 1) {
|
||||||
@ -157,6 +135,8 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const heroImageUrl = $derived(getHeroImageUrl());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,431 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, waitFor } from "@testing-library/svelte";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integration tests for async image loading pattern used in components
|
|
||||||
*
|
|
||||||
* Pattern:
|
|
||||||
* - Component has $state<string> imageUrl = ""
|
|
||||||
* - Component has async loadImageUrl() function
|
|
||||||
* - Component uses $effect to call loadImageUrl when dependencies change
|
|
||||||
* - For lists: uses Map<string, string> to cache URLs per item
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Mock repository with getImageUrl
|
|
||||||
const createMockRepository = () => ({
|
|
||||||
getImageUrl: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Async Image Loading Pattern", () => {
|
|
||||||
let mockRepository: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRepository = createMockRepository();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Single Image Loading", () => {
|
|
||||||
it("should load image URL asynchronously on component mount", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulating component with async image loading
|
|
||||||
const imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
|
|
||||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item123", "Primary");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show placeholder while loading", async () => {
|
|
||||||
mockRepository.getImageUrl.mockImplementation(
|
|
||||||
() => new Promise((resolve) => setTimeout(() => resolve("https://server.com/image.jpg"), 100))
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const promise = mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
|
|
||||||
// Initially no URL
|
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
vi.useRealTimers();
|
|
||||||
|
|
||||||
const result = await promise;
|
|
||||||
expect(result).toBe("https://server.com/image.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reload image when item changes", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image1.jpg");
|
|
||||||
|
|
||||||
const url1 = await mockRepository.getImageUrl("item1", "Primary");
|
|
||||||
expect(url1).toBe("https://server.com/image1.jpg");
|
|
||||||
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
|
||||||
|
|
||||||
const url2 = await mockRepository.getImageUrl("item2", "Primary");
|
|
||||||
expect(url2).toBe("https://server.com/image2.jpg");
|
|
||||||
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not reload image if item ID hasn't changed", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// First load
|
|
||||||
await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
|
|
||||||
// Would normally use $effect to track changes
|
|
||||||
// If item ID is same, should not reload (handled by component caching)
|
|
||||||
// This test documents the expected behavior
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle load errors gracefully", async () => {
|
|
||||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
// Component should catch error and show placeholder
|
|
||||||
try {
|
|
||||||
await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
} catch (e) {
|
|
||||||
expect(e).toBeInstanceOf(Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("List Image Caching (Map-based)", () => {
|
|
||||||
it("should cache URLs using Map<string, string>", () => {
|
|
||||||
// Simulating component state: imageUrls = $state<Map<string, string>>(new Map())
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
// Load first item
|
|
||||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
|
||||||
expect(imageUrls.has("item1")).toBe(true);
|
|
||||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
|
||||||
|
|
||||||
// Load second item
|
|
||||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
|
||||||
expect(imageUrls.size).toBe(2);
|
|
||||||
|
|
||||||
// Check cache hit
|
|
||||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should load images only once per item", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
// Simulate loading multiple items
|
|
||||||
const items = [
|
|
||||||
{ id: "item1", name: "Album 1" },
|
|
||||||
{ id: "item2", name: "Album 2" },
|
|
||||||
{ id: "item1", name: "Album 1 (again)" }, // Same ID
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!imageUrls.has(item.id)) {
|
|
||||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
|
||||||
imageUrls.set(item.id, url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only call once per unique ID
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update single item without affecting others", async () => {
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
|
||||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
|
||||||
imageUrls.set("item3", "https://server.com/image3.jpg");
|
|
||||||
|
|
||||||
// Update item2
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2_updated.jpg");
|
|
||||||
const newUrl = await mockRepository.getImageUrl("item2", "Primary");
|
|
||||||
imageUrls.set("item2", newUrl);
|
|
||||||
|
|
||||||
// Others should remain unchanged
|
|
||||||
expect(imageUrls.get("item1")).toBe("https://server.com/image1.jpg");
|
|
||||||
expect(imageUrls.get("item2")).toBe("https://server.com/image2_updated.jpg");
|
|
||||||
expect(imageUrls.get("item3")).toBe("https://server.com/image3.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear cache when data changes", () => {
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
imageUrls.set("item1", "https://server.com/image1.jpg");
|
|
||||||
imageUrls.set("item2", "https://server.com/image2.jpg");
|
|
||||||
|
|
||||||
// Clear cache
|
|
||||||
imageUrls.clear();
|
|
||||||
|
|
||||||
expect(imageUrls.size).toBe(0);
|
|
||||||
expect(imageUrls.has("item1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support Map operations efficiently", () => {
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
// Add items
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
imageUrls.set(`item${i}`, `https://server.com/image${i}.jpg`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(imageUrls.size).toBe(100);
|
|
||||||
|
|
||||||
// Check specific item
|
|
||||||
expect(imageUrls.has("item50")).toBe(true);
|
|
||||||
expect(imageUrls.get("item50")).toBe("https://server.com/image50.jpg");
|
|
||||||
|
|
||||||
// Iterate
|
|
||||||
let count = 0;
|
|
||||||
imageUrls.forEach(() => {
|
|
||||||
count++;
|
|
||||||
});
|
|
||||||
expect(count).toBe(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Component Lifecycle ($effect integration)", () => {
|
|
||||||
it("should trigger load on prop change", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulate $effect tracking prop changes
|
|
||||||
let effectCount = 0;
|
|
||||||
const trackingEffect = vi.fn(() => {
|
|
||||||
effectCount++;
|
|
||||||
return mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
});
|
|
||||||
|
|
||||||
trackingEffect();
|
|
||||||
expect(effectCount).toBe(1);
|
|
||||||
|
|
||||||
trackingEffect();
|
|
||||||
expect(effectCount).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should skip load if conditions not met", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulate conditional loading (e.g., if (!imageUrl && primaryImageTag))
|
|
||||||
let imageUrl = "";
|
|
||||||
const primaryImageTag = "";
|
|
||||||
|
|
||||||
if (!imageUrl && primaryImageTag) {
|
|
||||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle dependent state updates", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulate component state changes triggering effects
|
|
||||||
const state = {
|
|
||||||
item: { id: "item1", primaryImageTag: "tag1" },
|
|
||||||
imageUrl: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadImage = async () => {
|
|
||||||
if (state.item.primaryImageTag) {
|
|
||||||
state.imageUrl = await mockRepository.getImageUrl(state.item.id, "Primary");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await loadImage();
|
|
||||||
expect(state.imageUrl).toBe("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Change item
|
|
||||||
state.item = { id: "item2", primaryImageTag: "tag2" };
|
|
||||||
state.imageUrl = "";
|
|
||||||
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image2.jpg");
|
|
||||||
await loadImage();
|
|
||||||
expect(state.imageUrl).toBe("https://server.com/image2.jpg");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Handling in Async Loading", () => {
|
|
||||||
it("should set empty string on error", async () => {
|
|
||||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
let imageUrl = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
} catch {
|
|
||||||
imageUrl = ""; // Set to empty on error
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(imageUrl).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow retry after error", async () => {
|
|
||||||
mockRepository.getImageUrl
|
|
||||||
.mockRejectedValueOnce(new Error("Network error"))
|
|
||||||
.mockResolvedValueOnce("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
let imageUrl = "";
|
|
||||||
|
|
||||||
// First attempt fails
|
|
||||||
try {
|
|
||||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
} catch {
|
|
||||||
imageUrl = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry succeeds
|
|
||||||
imageUrl = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
expect(imageUrl).toBe("https://server.com/image.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle concurrent load requests", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulate loading multiple images concurrently
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
const items = [
|
|
||||||
{ id: "item1" },
|
|
||||||
{ id: "item2" },
|
|
||||||
{ id: "item3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const promises = items.map(item =>
|
|
||||||
mockRepository.getImageUrl(item.id, "Primary")
|
|
||||||
.then(url => imageUrls.set(item.id, url))
|
|
||||||
.catch(() => imageUrls.set(item.id, ""))
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
expect(imageUrls.size).toBe(3);
|
|
||||||
expect(imageUrls.has("item1")).toBe(true);
|
|
||||||
expect(imageUrls.has("item2")).toBe(true);
|
|
||||||
expect(imageUrls.has("item3")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Performance Characteristics", () => {
|
|
||||||
it("should not reload unnecessarily", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
// Simulate $effect with dependency tracking
|
|
||||||
let dependencyValue = "same";
|
|
||||||
let previousDependency = "same";
|
|
||||||
|
|
||||||
const loadImage = async () => {
|
|
||||||
if (dependencyValue !== previousDependency) {
|
|
||||||
previousDependency = dependencyValue;
|
|
||||||
return await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await loadImage();
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// No change in dependency
|
|
||||||
await loadImage();
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Change dependency
|
|
||||||
dependencyValue = "changed";
|
|
||||||
await loadImage();
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle large lists efficiently", async () => {
|
|
||||||
const imageUrls = new Map<string, string>();
|
|
||||||
let loadCount = 0;
|
|
||||||
|
|
||||||
mockRepository.getImageUrl.mockImplementation(() => {
|
|
||||||
loadCount++;
|
|
||||||
return Promise.resolve("https://server.com/image.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate loading 1000 items but caching URLs
|
|
||||||
const items = Array.from({ length: 1000 }, (_, i) => ({ id: `item${i % 10}` }));
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
if (!imageUrls.has(item.id)) {
|
|
||||||
const url = await mockRepository.getImageUrl(item.id, "Primary");
|
|
||||||
imageUrls.set(item.id, url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should only load 10 unique images
|
|
||||||
expect(loadCount).toBe(10);
|
|
||||||
expect(imageUrls.size).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not block rendering during async loading", () => {
|
|
||||||
mockRepository.getImageUrl.mockImplementation(
|
|
||||||
() => new Promise((resolve) =>
|
|
||||||
setTimeout(() => resolve("https://server.com/image.jpg"), 1000)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Async operation should not block component rendering
|
|
||||||
const renderTiming = {
|
|
||||||
startRender: Date.now(),
|
|
||||||
loadStart: null as number | null,
|
|
||||||
loadComplete: null as number | null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render happens immediately
|
|
||||||
renderTiming.startRender = Date.now();
|
|
||||||
|
|
||||||
// Load happens asynchronously
|
|
||||||
mockRepository.getImageUrl("item123", "Primary").then(() => {
|
|
||||||
renderTiming.loadComplete = Date.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render should complete before load finishes
|
|
||||||
expect(Date.now() - renderTiming.startRender).toBeLessThan(1000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Backend Integration", () => {
|
|
||||||
it("should call backend with correct parameters", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image.jpg");
|
|
||||||
|
|
||||||
await mockRepository.getImageUrl("item123", "Primary", {
|
|
||||||
maxWidth: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
|
||||||
"item123",
|
|
||||||
"Primary",
|
|
||||||
{
|
|
||||||
maxWidth: 300,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle backend URL correctly", async () => {
|
|
||||||
const backendUrl = "https://server.com/Items/item123/Images/Primary?maxWidth=300&api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue(backendUrl);
|
|
||||||
|
|
||||||
const url = await mockRepository.getImageUrl("item123", "Primary", { maxWidth: 300 });
|
|
||||||
|
|
||||||
expect(url).toBe(backendUrl);
|
|
||||||
// Frontend never constructs URLs directly
|
|
||||||
expect(url).toContain("api_key=");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not require URL construction in frontend", async () => {
|
|
||||||
// Frontend receives pre-constructed URL from backend
|
|
||||||
const preConstructedUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue(preConstructedUrl);
|
|
||||||
|
|
||||||
const url = await mockRepository.getImageUrl("item123", "Primary");
|
|
||||||
|
|
||||||
// Frontend just uses the URL
|
|
||||||
expect(url).toContain("https://");
|
|
||||||
expect(url).toContain("item123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -10,9 +10,6 @@
|
|||||||
|
|
||||||
let { people, title = "Cast & Crew" }: Props = $props();
|
let { people, title = "Cast & Crew" }: Props = $props();
|
||||||
|
|
||||||
// Map of person IDs to their image URLs, loaded asynchronously
|
|
||||||
let personImageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
// Group people by type
|
// Group people by type
|
||||||
const groupedPeople = $derived.by(() => {
|
const groupedPeople = $derived.by(() => {
|
||||||
const groups: Record<string, Person[]> = {
|
const groups: Record<string, Person[]> = {
|
||||||
@ -61,31 +58,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single person
|
function getPersonImageUrl(person: Person): string {
|
||||||
async function loadPersonImageUrl(person: Person): Promise<void> {
|
|
||||||
if (!person.primaryImageTag || personImageUrls.has(person.id)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const url = await repo.getImageUrl(person.id, "Primary", {
|
return repo.getImageUrl(person.id, "Primary", {
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
tag: person.primaryImageTag,
|
tag: person.primaryImageTag,
|
||||||
});
|
});
|
||||||
personImageUrls.set(person.id, url);
|
|
||||||
} catch {
|
} catch {
|
||||||
personImageUrls.set(person.id, "");
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URLs for all people
|
|
||||||
$effect(() => {
|
|
||||||
people.forEach((person) => {
|
|
||||||
if (person.primaryImageTag && !personImageUrls.has(person.id)) {
|
|
||||||
loadPersonImageUrl(person);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function handlePersonClick(person: Person) {
|
function handlePersonClick(person: Person) {
|
||||||
goto(`/library/${person.id}`);
|
goto(`/library/${person.id}`);
|
||||||
}
|
}
|
||||||
@ -110,9 +94,9 @@
|
|||||||
>
|
>
|
||||||
<!-- Person image -->
|
<!-- Person image -->
|
||||||
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
<div class="w-24 h-24 rounded-full overflow-hidden bg-[var(--color-surface)] mb-2">
|
||||||
{#if person.primaryImageTag && personImageUrls.get(person.id)}
|
{#if person.primaryImageTag}
|
||||||
<img
|
<img
|
||||||
src={personImageUrls.get(person.id)}
|
src={getPersonImageUrl(person)}
|
||||||
alt={person.name}
|
alt={person.name}
|
||||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
class="w-full h-full object-cover group-hover:scale-110 transition-transform"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@ -12,9 +12,6 @@
|
|||||||
|
|
||||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||||
|
|
||||||
let backdropUrl = $state<string>("");
|
|
||||||
let episodeThumbnailUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||||
if (ep.id === episode.id) return true;
|
if (ep.id === episode.id) return true;
|
||||||
@ -73,74 +70,52 @@
|
|||||||
return allEpisodes.slice(start, end);
|
return allEpisodes.slice(start, end);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load backdrop URL asynchronously
|
function getBackdropUrl(): string {
|
||||||
async function loadBackdropUrl(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
// Try episode backdrop first
|
// Try episode backdrop first
|
||||||
if (episode.backdropImageTags?.[0]) {
|
if (episode.backdropImageTags?.[0]) {
|
||||||
backdropUrl = await repo.getImageUrl(episode.id, "Backdrop", {
|
return repo.getImageUrl(episode.id, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: episode.backdropImageTags[0],
|
tag: episode.backdropImageTags[0],
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try episode primary (thumbnail)
|
// Try episode primary (thumbnail)
|
||||||
if (episode.primaryImageTag) {
|
if (episode.primaryImageTag) {
|
||||||
backdropUrl = await repo.getImageUrl(episode.id, "Primary", {
|
return repo.getImageUrl(episode.id, "Primary", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: episode.primaryImageTag,
|
tag: episode.primaryImageTag,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to series backdrop
|
// Fall back to series backdrop
|
||||||
if (series.backdropImageTags?.[0]) {
|
if (series.backdropImageTags?.[0]) {
|
||||||
backdropUrl = await repo.getImageUrl(series.id, "Backdrop", {
|
return repo.getImageUrl(series.id, "Backdrop", {
|
||||||
maxWidth: 1920,
|
maxWidth: 1920,
|
||||||
tag: series.backdropImageTags[0],
|
tag: series.backdropImageTags[0],
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backdropUrl = "";
|
return "";
|
||||||
} catch {
|
} catch {
|
||||||
backdropUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load episode thumbnail URL for a single episode
|
function getEpisodeThumbnail(ep: MediaItem): string {
|
||||||
async function loadEpisodeThumbnailUrl(ep: MediaItem): Promise<void> {
|
|
||||||
if (!ep.primaryImageTag || episodeThumbnailUrls.has(ep.id)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const url = await repo.getImageUrl(ep.id, "Primary", {
|
return repo.getImageUrl(ep.id, "Primary", {
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
tag: ep.primaryImageTag,
|
tag: ep.primaryImageTag,
|
||||||
});
|
});
|
||||||
episodeThumbnailUrls.set(ep.id, url);
|
|
||||||
} catch {
|
} catch {
|
||||||
episodeThumbnailUrls.set(ep.id, "");
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load backdrop when episode changes
|
|
||||||
$effect(() => {
|
|
||||||
loadBackdropUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load episode thumbnail URLs when adjacent episodes change
|
|
||||||
$effect(() => {
|
|
||||||
adjacentEpisodes().forEach((ep) => {
|
|
||||||
if (ep.primaryImageTag && !episodeThumbnailUrls.has(ep.id)) {
|
|
||||||
loadEpisodeThumbnailUrl(ep);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatDuration(ticks?: number): string {
|
function formatDuration(ticks?: number): string {
|
||||||
if (!ticks) return "";
|
if (!ticks) return "";
|
||||||
const seconds = Math.floor(ticks / 10000000);
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
@ -168,6 +143,7 @@
|
|||||||
goto(`/library/${series.id}?episode=${ep.id}`);
|
goto(`/library/${series.id}?episode=${ep.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backdropUrl = $derived(getBackdropUrl());
|
||||||
const episodeLabel = $derived(
|
const episodeLabel = $derived(
|
||||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||||
);
|
);
|
||||||
@ -288,7 +264,7 @@
|
|||||||
{#each adjacentEpisodes() as ep (ep.id)}
|
{#each adjacentEpisodes() as ep (ep.id)}
|
||||||
{@const isCurrent = isCurrentEpisode(ep)}
|
{@const isCurrent = isCurrentEpisode(ep)}
|
||||||
{@const epProgress = getProgress(ep)}
|
{@const epProgress = getProgress(ep)}
|
||||||
{@const thumbUrl = episodeThumbnailUrls.get(ep.id) ?? ""}
|
{@const thumbUrl = getEpisodeThumbnail(ep)}
|
||||||
<button
|
<button
|
||||||
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
||||||
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
|
||||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -15,7 +14,6 @@
|
|||||||
let { episode, focused = false, onclick }: Props = $props();
|
let { episode, focused = false, onclick }: Props = $props();
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement | null = null;
|
let buttonRef: HTMLButtonElement | null = null;
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (focused && buttonRef) {
|
if (focused && buttonRef) {
|
||||||
@ -37,31 +35,39 @@
|
|||||||
);
|
);
|
||||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||||
|
|
||||||
// Load image URL asynchronously
|
function getImageUrl(): string {
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
imageUrl = await repo.getImageUrl(episode.id, "Primary", {
|
return repo.getImageUrl(episode.id, "Primary", {
|
||||||
maxWidth: 320,
|
maxWidth: 320,
|
||||||
tag: episode.primaryImageTag,
|
tag: episode.primaryImageTag,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
imageUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image when episode changes
|
function getProgress(): number {
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const progress = $derived(() => {
|
|
||||||
if (!episode.userData || !episode.runTimeTicks) {
|
if (!episode.userData || !episode.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
|
return (episode.userData.playbackPositionTicks / episode.runTimeTicks) * 100;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function formatDuration(ticks?: number): string {
|
||||||
|
if (!ticks) return "";
|
||||||
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = $derived(getImageUrl());
|
||||||
|
const progress = $derived(getProgress());
|
||||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||||
</script>
|
</script>
|
||||||
@ -101,11 +107,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
{#if progress() > 0}
|
{#if progress > 0}
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||||
<div
|
<div
|
||||||
class="h-full bg-[var(--color-jellyfin)]"
|
class="h-full bg-[var(--color-jellyfin)]"
|
||||||
style="width: {progress()}%"
|
style="width: {progress}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -41,7 +41,6 @@
|
|||||||
let selectedGenre = $state<Genre | null>(null);
|
let selectedGenre = $state<Genre | null>(null);
|
||||||
let genreItems = $state<MediaItem[]>([]);
|
let genreItems = $state<MediaItem[]>([]);
|
||||||
let loadingItems = $state(false);
|
let loadingItems = $state(false);
|
||||||
let genreItemImageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||||
await loadGenres();
|
await loadGenres();
|
||||||
@ -80,7 +79,6 @@
|
|||||||
try {
|
try {
|
||||||
loadingItems = true;
|
loadingItems = true;
|
||||||
selectedGenre = genre;
|
selectedGenre = genre;
|
||||||
genreItemImageUrls = new Map(); // Clear image URLs when loading new genre
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const result = await repo.getItems($currentLibrary.id, {
|
const result = await repo.getItems($currentLibrary.id, {
|
||||||
includeItemTypes: config.itemTypes,
|
includeItemTypes: config.itemTypes,
|
||||||
@ -98,31 +96,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single item
|
|
||||||
async function loadGenreItemImage(item: MediaItem): Promise<void> {
|
|
||||||
if (!item.primaryImageTag || genreItemImageUrls.has(item.id)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
|
||||||
maxWidth: 300,
|
|
||||||
tag: item.primaryImageTag,
|
|
||||||
});
|
|
||||||
genreItemImageUrls.set(item.id, url);
|
|
||||||
} catch {
|
|
||||||
genreItemImageUrls.set(item.id, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load image URLs for all genre items
|
|
||||||
$effect(() => {
|
|
||||||
genreItems.forEach((item) => {
|
|
||||||
if (item.primaryImageTag && !genreItemImageUrls.has(item.id)) {
|
|
||||||
loadGenreItemImage(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyFilter() {
|
function applyFilter() {
|
||||||
let result = [...genres];
|
let result = [...genres];
|
||||||
|
|
||||||
@ -244,9 +217,12 @@
|
|||||||
{#each genreItems as item (item.id)}
|
{#each genreItems as item (item.id)}
|
||||||
<button onclick={() => handleItemClick(item)} class="group text-left">
|
<button onclick={() => handleItemClick(item)} class="group text-left">
|
||||||
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
<div class="{aspectRatioClass} bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2">
|
||||||
{#if item.primaryImageTag && genreItemImageUrls.get(item.id)}
|
{#if item.primaryImageTag}
|
||||||
<img
|
<img
|
||||||
src={genreItemImageUrls.get(item.id)}
|
src={auth.getRepository().getImageUrl(item.id, "Primary", {
|
||||||
|
maxWidth: 300,
|
||||||
|
tag: item.primaryImageTag,
|
||||||
|
})}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -27,9 +27,10 @@
|
|||||||
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
title: string; // "Albums", "Artists", "Playlists", "Tracks"
|
||||||
backPath: string; // "/library/music"
|
backPath: string; // "/library/music"
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
sortOptions: Array<{ key: string; label: string }>; // Jellyfin field names
|
sortOptions: SortOption[];
|
||||||
defaultSort: string; // Jellyfin field name (e.g., "SortName")
|
defaultSort: string;
|
||||||
displayComponent: "grid" | "tracklist"; // Which component to use
|
displayComponent: "grid" | "tracklist"; // Which component to use
|
||||||
|
searchFields: string[]; // Which fields to search in: ["name", "artists"], etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -39,12 +40,10 @@
|
|||||||
let { config }: Props = $props();
|
let { config }: Props = $props();
|
||||||
|
|
||||||
let items = $state<MediaItem[]>([]);
|
let items = $state<MediaItem[]>([]);
|
||||||
|
let filteredItems = $state<MediaItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
let debouncedSearchQuery = $state("");
|
|
||||||
let sortBy = $state<string>(config.defaultSort);
|
let sortBy = $state<string>(config.defaultSort);
|
||||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const { markLoaded } = useServerReachabilityReload(async () => {
|
const { markLoaded } = useServerReachabilityReload(async () => {
|
||||||
await loadItems();
|
await loadItems();
|
||||||
@ -64,24 +63,14 @@
|
|||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
// Use backend search if search query is provided, otherwise use getItems with sort
|
|
||||||
if (debouncedSearchQuery.trim()) {
|
|
||||||
const result = await repo.search(debouncedSearchQuery, {
|
|
||||||
includeItemTypes: [config.itemType],
|
|
||||||
limit: 10000,
|
|
||||||
});
|
|
||||||
items = result.items;
|
|
||||||
} else {
|
|
||||||
const result = await repo.getItems($currentLibrary.id, {
|
const result = await repo.getItems($currentLibrary.id, {
|
||||||
includeItemTypes: [config.itemType],
|
includeItemTypes: [config.itemType],
|
||||||
sortBy,
|
sortBy: "SortName",
|
||||||
sortOrder,
|
sortOrder: "Ascending",
|
||||||
recursive: true,
|
recursive: true,
|
||||||
limit: 10000,
|
|
||||||
});
|
});
|
||||||
items = result.items;
|
items = result.items;
|
||||||
}
|
applySortAndFilter();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to load ${config.itemType}:`, e);
|
console.error(`Failed to load ${config.itemType}:`, e);
|
||||||
} finally {
|
} finally {
|
||||||
@ -89,28 +78,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch(query: string) {
|
function applySortAndFilter() {
|
||||||
searchQuery = query;
|
let result = [...items];
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter((item) => {
|
||||||
|
return config.searchFields.some((field) => {
|
||||||
|
if (field === "artists" && item.artists) {
|
||||||
|
return item.artists.some((a) => a.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
const value = item[field as keyof MediaItem];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.toLowerCase().includes(query);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce search input (300ms delay)
|
// Apply sorting - find the matching sort option and use its compareFn
|
||||||
$effect(() => {
|
const selectedSortOption = config.sortOptions.find((opt) => opt.key === sortBy);
|
||||||
if (searchTimeout) clearTimeout(searchTimeout);
|
if (selectedSortOption && "compareFn" in selectedSortOption) {
|
||||||
|
result.sort(selectedSortOption.compareFn as (a: MediaItem, b: MediaItem) => number);
|
||||||
|
}
|
||||||
|
|
||||||
searchTimeout = setTimeout(() => {
|
filteredItems = result;
|
||||||
debouncedSearchQuery = searchQuery;
|
}
|
||||||
loadItems();
|
|
||||||
}, 300);
|
function handleSearch(query: string) {
|
||||||
});
|
searchQuery = query;
|
||||||
|
applySortAndFilter();
|
||||||
|
}
|
||||||
|
|
||||||
function handleSort(newSort: string) {
|
function handleSort(newSort: string) {
|
||||||
sortBy = newSort;
|
sortBy = newSort;
|
||||||
loadItems();
|
applySortAndFilter();
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSortOrder() {
|
|
||||||
sortOrder = sortOrder === "Ascending" ? "Descending" : "Ascending";
|
|
||||||
loadItems();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
@ -156,7 +160,7 @@
|
|||||||
|
|
||||||
<!-- Results Count -->
|
<!-- Results Count -->
|
||||||
{#if !loading}
|
{#if !loading}
|
||||||
<ResultsCounter count={items.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
|
<ResultsCounter count={filteredItems.length} itemType={config.itemType.toLowerCase()} searchQuery={searchQuery} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Items List/Grid -->
|
<!-- Items List/Grid -->
|
||||||
@ -177,15 +181,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if items.length === 0}
|
{:else if filteredItems.length === 0}
|
||||||
<div class="text-center py-12 text-gray-400">
|
<div class="text-center py-12 text-gray-400">
|
||||||
<p>No {config.title.toLowerCase()} found</p>
|
<p>No {config.title.toLowerCase()} found</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if config.displayComponent === "grid"}
|
{#if config.displayComponent === "grid"}
|
||||||
<LibraryGrid items={items} onItemClick={handleItemClick} />
|
<LibraryGrid items={filteredItems} onItemClick={handleItemClick} />
|
||||||
{:else if config.displayComponent === "tracklist"}
|
{:else if config.displayComponent === "tracklist"}
|
||||||
<TrackList tracks={items} onTrackClick={handleTrackClick} />
|
<TrackList tracks={filteredItems} onTrackClick={handleTrackClick} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,661 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
|
||||||
import GenericMediaListPage from "./GenericMediaListPage.svelte";
|
|
||||||
|
|
||||||
// Mock SvelteKit navigation
|
|
||||||
vi.mock("$app/navigation", () => ({
|
|
||||||
goto: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock stores
|
|
||||||
vi.mock("$lib/stores/library", () => ({
|
|
||||||
currentLibrary: {
|
|
||||||
subscribe: vi.fn((fn) => {
|
|
||||||
fn({ id: "lib123", name: "Music" });
|
|
||||||
return vi.fn();
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/auth", () => ({
|
|
||||||
auth: {
|
|
||||||
getRepository: vi.fn(() => ({
|
|
||||||
getItems: vi.fn(),
|
|
||||||
search: vi.fn(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/composables/useServerReachabilityReload", () => ({
|
|
||||||
useServerReachabilityReload: vi.fn(() => ({
|
|
||||||
markLoaded: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("GenericMediaListPage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Component Initialization", () => {
|
|
||||||
it("should render with title and search bar", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
const heading = screen.getByText("Tracks");
|
|
||||||
expect(heading).toBeTruthy();
|
|
||||||
|
|
||||||
const searchInput = container.querySelector('input[type="text"]');
|
|
||||||
expect(searchInput).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should load items on mount", async () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// loadItems should have been called
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display sort options", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "MusicAlbum",
|
|
||||||
title: "Albums",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search albums...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
{ key: "Artist", label: "Artist" },
|
|
||||||
{ key: "ProductionYear", label: "Year" },
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that all sort options are rendered
|
|
||||||
const titleOption = screen.queryByText("Title");
|
|
||||||
expect(titleOption).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Search Functionality", () => {
|
|
||||||
it("should debounce search input for 300ms", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
|
||||||
|
|
||||||
// Type into search
|
|
||||||
fireEvent.input(searchInput, { target: { value: "t" } });
|
|
||||||
expect(searchInput.value).toBe("t");
|
|
||||||
|
|
||||||
// Search should not trigger immediately
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// Add more characters
|
|
||||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
|
||||||
|
|
||||||
// Still shouldn't trigger (only 100ms passed total)
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// Now advance to 300ms total - search should trigger
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Search should have been debounced
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use backend search when search query is provided", async () => {
|
|
||||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [{ id: "item1", name: "Test Track" }],
|
|
||||||
totalRecordCount: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: vi.fn(),
|
|
||||||
search: mockSearchFn,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
|
||||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
|
||||||
|
|
||||||
// Advance timer to trigger debounced search
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
limit: 10000,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use getItems without search for empty query", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear previous search when input becomes empty", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: mockSearchFn,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
|
||||||
|
|
||||||
// Type search query
|
|
||||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Clear search
|
|
||||||
fireEvent.input(searchInput, { target: { value: "" } });
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should call getItems when search is cleared
|
|
||||||
expect(mockGetItemsFn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Sorting Functionality", () => {
|
|
||||||
it("should pass sortBy parameter to backend", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "MusicAlbum",
|
|
||||||
title: "Albums",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search albums...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
{ key: "Artist", label: "Artist" },
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
|
||||||
sortBy: "SortName",
|
|
||||||
sortOrder: "Ascending",
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pass Jellyfin field names to backend (not custom compareFn)", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
{ key: "Artist", label: "Artist" },
|
|
||||||
{ key: "Album", label: "Album" },
|
|
||||||
{ key: "DatePlayed", label: "Recent" },
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const lastCall = (mockGetItemsFn as any).mock.calls[0];
|
|
||||||
const options = lastCall[1];
|
|
||||||
|
|
||||||
// Should pass Jellyfin field names directly
|
|
||||||
expect(typeof options.sortBy).toBe("string");
|
|
||||||
expect(["SortName", "Artist", "Album", "DatePlayed"]).toContain(options.sortBy);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ItemType Filtering", () => {
|
|
||||||
it("should include correct itemType in getItems request", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.objectContaining({
|
|
||||||
includeItemTypes: ["Audio"],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include correct itemType in search request", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
totalRecordCount: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: vi.fn(),
|
|
||||||
search: mockSearchFn,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "MusicAlbum",
|
|
||||||
title: "Albums",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search albums...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
|
||||||
fireEvent.input(searchInput, { target: { value: "album" } });
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSearchFn).toHaveBeenCalledWith("album", expect.objectContaining({
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Loading State", () => {
|
|
||||||
it("should show loading indicator during data fetch", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn(
|
|
||||||
() => new Promise((resolve) => setTimeout(
|
|
||||||
() => resolve({ items: [], totalRecordCount: 0 }),
|
|
||||||
100
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Component should be rendering (will show loading state internally)
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Error Handling", () => {
|
|
||||||
it("should handle backend errors gracefully", async () => {
|
|
||||||
const mockGetItemsFn = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Should handle error without throwing
|
|
||||||
expect(mockGetItemsFn).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle missing library gracefully", async () => {
|
|
||||||
const { goto } = await import("$app/navigation");
|
|
||||||
|
|
||||||
const mockGetItemsFn = vi.fn();
|
|
||||||
|
|
||||||
const mockRepository = {
|
|
||||||
getItems: mockGetItemsFn,
|
|
||||||
search: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
|
||||||
mockRepository as any
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock currentLibrary to return null
|
|
||||||
vi.resetModules();
|
|
||||||
vi.mocked((await import("$lib/stores/library")).currentLibrary.subscribe).mockImplementation(
|
|
||||||
(fn: any) => {
|
|
||||||
fn(null);
|
|
||||||
return vi.fn();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should navigate to back path when library is missing
|
|
||||||
await waitFor(() => {
|
|
||||||
// goto would be called with config.backPath
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Display Component Props", () => {
|
|
||||||
it("should support grid display component", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "MusicAlbum",
|
|
||||||
title: "Albums",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search albums...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "grid" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should support tracklist display component", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Config Simplification", () => {
|
|
||||||
it("should not require searchFields in config", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
// Note: searchFields is NOT present
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should render without searchFields
|
|
||||||
expect(() => {
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not require compareFn in sort options", () => {
|
|
||||||
const config = {
|
|
||||||
itemType: "Audio",
|
|
||||||
title: "Tracks",
|
|
||||||
backPath: "/library/music",
|
|
||||||
searchPlaceholder: "Search tracks...",
|
|
||||||
sortOptions: [
|
|
||||||
{ key: "SortName", label: "Title" },
|
|
||||||
// Note: no compareFn property
|
|
||||||
],
|
|
||||||
defaultSort: "SortName",
|
|
||||||
displayComponent: "tracklist" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Should render without compareFn in sort options
|
|
||||||
expect(() => {
|
|
||||||
render(GenericMediaListPage, {
|
|
||||||
props: { config },
|
|
||||||
});
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,7 +2,6 @@
|
|||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: (MediaItem | Library)[];
|
items: (MediaItem | Library)[];
|
||||||
@ -13,37 +12,23 @@
|
|||||||
|
|
||||||
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
let { items, showProgress = false, showDownloadStatus = true, onItemClick }: Props = $props();
|
||||||
|
|
||||||
// Map of item IDs to their image URLs, loaded asynchronously
|
|
||||||
let imageUrls = $state<Map<string, string>>(new Map());
|
|
||||||
|
|
||||||
function getDownloadInfo(itemId: string) {
|
function getDownloadInfo(itemId: string) {
|
||||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL for a single item
|
function getImageUrl(item: MediaItem | Library): string {
|
||||||
async function loadImageUrl(item: MediaItem | Library): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||||
const url = await repo.getImageUrl(item.id, "Primary", {
|
return repo.getImageUrl(item.id, "Primary", {
|
||||||
maxWidth: 80,
|
maxWidth: 80,
|
||||||
tag,
|
tag,
|
||||||
});
|
});
|
||||||
imageUrls.set(item.id, url);
|
|
||||||
} catch {
|
} catch {
|
||||||
imageUrls.set(item.id, "");
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URLs whenever items change
|
|
||||||
$effect(() => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!imageUrls.has(item.id)) {
|
|
||||||
loadImageUrl(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function getSubtitle(item: MediaItem | Library): string {
|
function getSubtitle(item: MediaItem | Library): string {
|
||||||
if (!("type" in item)) return "";
|
if (!("type" in item)) return "";
|
||||||
|
|
||||||
@ -62,6 +47,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ticks?: number): string {
|
||||||
|
if (!ticks) return "";
|
||||||
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function getProgress(item: MediaItem | Library): number {
|
function getProgress(item: MediaItem | Library): number {
|
||||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||||
@ -80,7 +72,7 @@
|
|||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each items as item, index (item.id)}
|
{#each items as item, index (item.id)}
|
||||||
{@const imageUrl = imageUrls.get(item.id) ?? ""}
|
{@const imageUrl = getImageUrl(item)}
|
||||||
{@const subtitle = getSubtitle(item)}
|
{@const subtitle = getSubtitle(item)}
|
||||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||||
{@const progress = getProgress(item)}
|
{@const progress = getProgress(item)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import type { MediaItem, Library } from "$lib/api/types";
|
import type { MediaItem, Library } from "$lib/api/types";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { downloads } from "$lib/stores/downloads";
|
import { downloads } from "$lib/stores/downloads";
|
||||||
|
import { getImageUrlSync } from "$lib/services/imageCache";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: MediaItem | Library;
|
item: MediaItem | Library;
|
||||||
@ -13,9 +14,6 @@
|
|||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||||
|
|
||||||
// Image URL state - loaded asynchronously
|
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
// Check if this item is downloaded
|
// Check if this item is downloaded
|
||||||
const downloadInfo = $derived(
|
const downloadInfo = $derived(
|
||||||
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
Object.values($downloads.downloads).find((d) => d.itemId === item.id)
|
||||||
@ -42,35 +40,32 @@
|
|||||||
return "aspect-video";
|
return "aspect-video";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load image URL asynchronously from backend
|
function getImageUrl(): string {
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
const serverUrl = repo.serverUrl;
|
||||||
|
const id = item.id;
|
||||||
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
const tag = "primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined);
|
||||||
|
const maxWidth = size === "large" ? 400 : size === "medium" ? 300 : 200;
|
||||||
|
|
||||||
imageUrl = await repo.getImageUrl(item.id, "Primary", {
|
// Use the caching service - returns server URL immediately and triggers background caching
|
||||||
|
return getImageUrlSync(serverUrl, id, "Primary", {
|
||||||
maxWidth,
|
maxWidth,
|
||||||
tag,
|
tag,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
imageUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL whenever item or size changes
|
function getProgress(): number {
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const progress = $derived(() => {
|
|
||||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
return (item.userData.playbackPositionTicks / item.runTimeTicks) * 100;
|
||||||
});
|
}
|
||||||
|
|
||||||
const subtitle = $derived(() => {
|
function getSubtitle(): string {
|
||||||
if (!("type" in item)) return "";
|
if (!("type" in item)) return "";
|
||||||
|
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
@ -87,7 +82,11 @@
|
|||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const imageUrl = $derived(getImageUrl());
|
||||||
|
const progress = $derived(getProgress());
|
||||||
|
const subtitle = $derived(getSubtitle());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -123,11 +122,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
{#if progress() > 0}
|
{#if progress > 0}
|
||||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
||||||
<div
|
<div
|
||||||
class="h-full bg-[var(--color-jellyfin)]"
|
class="h-full bg-[var(--color-jellyfin)]"
|
||||||
style="width: {progress()}%"
|
style="width: {progress}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -189,8 +188,8 @@
|
|||||||
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
<p class="text-sm font-medium text-white truncate group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{item.name}
|
{item.name}
|
||||||
</p>
|
</p>
|
||||||
{#if subtitle()}
|
{#if subtitle}
|
||||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
<p class="text-xs text-gray-400 truncate">{subtitle}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -1,359 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/svelte";
|
|
||||||
import MediaCard from "./MediaCard.svelte";
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/auth", () => ({
|
|
||||||
auth: {
|
|
||||||
getRepository: vi.fn(() => ({
|
|
||||||
getImageUrl: vi.fn(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("MediaCard - Async Image Loading", () => {
|
|
||||||
let mockRepository: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockRepository = {
|
|
||||||
getImageUrl: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked((global as any).__stores_auth?.auth?.getRepository).mockReturnValue(mockRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Image Loading", () => {
|
|
||||||
it("should load image URL asynchronously", async () => {
|
|
||||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Component should render immediately with placeholder
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
|
|
||||||
// Wait for image URL to load
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
|
||||||
"item123",
|
|
||||||
"Primary",
|
|
||||||
expect.objectContaining({
|
|
||||||
maxWidth: 300,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should show placeholder while image is loading", async () => {
|
|
||||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockImplementation(
|
|
||||||
() => new Promise((resolve) => setTimeout(() => resolve(mockImageUrl), 100))
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Placeholder should be visible initially
|
|
||||||
const placeholder = container.querySelector(".placeholder");
|
|
||||||
if (placeholder) {
|
|
||||||
expect(placeholder).toBeTruthy();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for image to load
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
vi.useRealTimers();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update image URL when item changes", async () => {
|
|
||||||
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
|
|
||||||
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
|
|
||||||
|
|
||||||
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl1);
|
|
||||||
|
|
||||||
const mediaItem1 = {
|
|
||||||
id: "item1",
|
|
||||||
name: "Album 1",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "tag1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rerender } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item1", "Primary", expect.any(Object));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change item
|
|
||||||
mockRepository.getImageUrl.mockResolvedValueOnce(mockImageUrl2);
|
|
||||||
|
|
||||||
const mediaItem2 = {
|
|
||||||
id: "item2",
|
|
||||||
name: "Album 2",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "tag2",
|
|
||||||
};
|
|
||||||
|
|
||||||
await rerender({ item: mediaItem2 });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith("item2", "Primary", expect.any(Object));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not reload image if item ID hasn't changed", async () => {
|
|
||||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rerender } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rerender with same item
|
|
||||||
await rerender({ item: mediaItem });
|
|
||||||
|
|
||||||
// Should not call getImageUrl again
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle missing primary image tag gracefully", async () => {
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
// primaryImageTag is undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should render without calling getImageUrl
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show placeholder
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image load errors gracefully", async () => {
|
|
||||||
mockRepository.getImageUrl.mockRejectedValue(new Error("Failed to load image"));
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { container } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should still render without crashing
|
|
||||||
expect(container).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Image Options", () => {
|
|
||||||
it("should pass correct options to getImageUrl", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
|
||||||
"item123",
|
|
||||||
"Primary",
|
|
||||||
{
|
|
||||||
maxWidth: 300,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include tag in image options when available", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "tag123",
|
|
||||||
};
|
|
||||||
|
|
||||||
render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledWith(
|
|
||||||
"item123",
|
|
||||||
"Primary",
|
|
||||||
{
|
|
||||||
maxWidth: 300,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Caching", () => {
|
|
||||||
it("should cache image URLs to avoid duplicate requests", async () => {
|
|
||||||
const mockImageUrl = "https://server.com/Items/item123/Images/Primary?api_key=token";
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue(mockImageUrl);
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render same item multiple times
|
|
||||||
const { rerender } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rerender with same item
|
|
||||||
await rerender({ item: mediaItem });
|
|
||||||
|
|
||||||
// Should still only have called once (cached)
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have separate cache entries for different items", async () => {
|
|
||||||
const mockImageUrl1 = "https://server.com/Items/item1/Images/Primary?api_key=token";
|
|
||||||
const mockImageUrl2 = "https://server.com/Items/item2/Images/Primary?api_key=token";
|
|
||||||
|
|
||||||
let callCount = 0;
|
|
||||||
mockRepository.getImageUrl.mockImplementation(() => {
|
|
||||||
callCount++;
|
|
||||||
return Promise.resolve(callCount === 1 ? mockImageUrl1 : mockImageUrl2);
|
|
||||||
});
|
|
||||||
|
|
||||||
const item1 = {
|
|
||||||
id: "item1",
|
|
||||||
name: "Album 1",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "tag1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const item2 = {
|
|
||||||
id: "item2",
|
|
||||||
name: "Album 2",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "tag2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rerender } = render(MediaCard, {
|
|
||||||
props: { item: item1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await rerender({ item: item2 });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change back to item 1 - should use cached value
|
|
||||||
await rerender({ item: item1 });
|
|
||||||
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Reactive Updates", () => {
|
|
||||||
it("should respond to property changes via $effect", async () => {
|
|
||||||
mockRepository.getImageUrl.mockResolvedValue("https://server.com/image");
|
|
||||||
|
|
||||||
const mediaItem = {
|
|
||||||
id: "item123",
|
|
||||||
name: "Test Album",
|
|
||||||
type: "MusicAlbum",
|
|
||||||
primaryImageTag: "abc123",
|
|
||||||
};
|
|
||||||
|
|
||||||
const { rerender } = render(MediaCard, {
|
|
||||||
props: { item: mediaItem },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRepository.getImageUrl).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousCallCount = mockRepository.getImageUrl.mock.calls.length;
|
|
||||||
|
|
||||||
// Update a property that shouldn't trigger reload
|
|
||||||
await rerender({
|
|
||||||
item: {
|
|
||||||
...mediaItem,
|
|
||||||
name: "Updated Album Name",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not call getImageUrl again (same primaryImageTag)
|
|
||||||
expect(mockRepository.getImageUrl.mock.calls.length).toBe(previousCallCount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -14,7 +14,6 @@
|
|||||||
let movies = $state<MediaItem[]>([]);
|
let movies = $state<MediaItem[]>([]);
|
||||||
let series = $state<MediaItem[]>([]);
|
let series = $state<MediaItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let imageUrl = $state<string>("");
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadFilmography();
|
await loadFilmography();
|
||||||
@ -39,27 +38,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image URL asynchronously
|
function getImageUrl(): string {
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
imageUrl = await repo.getImageUrl(person.id, "Primary", {
|
return repo.getImageUrl(person.id, "Primary", {
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
tag: person.primaryImageTag,
|
tag: person.primaryImageTag,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
imageUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image when person changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleItemClick(item: MediaItem) {
|
function handleItemClick(item: MediaItem) {
|
||||||
goto(`/library/${item.id}`);
|
goto(`/library/${item.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageUrl = $derived(getImageUrl());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
|||||||
@ -13,26 +13,19 @@
|
|||||||
|
|
||||||
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
|
||||||
|
|
||||||
let imageUrl = $state<string>("");
|
function getImageUrl(): string {
|
||||||
|
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
imageUrl = await repo.getImageUrl(season.id, "Primary", {
|
return repo.getImageUrl(season.id, "Primary", {
|
||||||
maxWidth: 200,
|
maxWidth: 200,
|
||||||
tag: season.primaryImageTag,
|
tag: season.primaryImageTag,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
imageUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image when season changes
|
const imageUrl = $derived(getImageUrl());
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
const episodeCount = $derived(episodes.length);
|
const episodeCount = $derived(episodes.length);
|
||||||
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
|
||||||
const seasonName = $derived(
|
const seasonName = $derived(
|
||||||
|
|||||||
@ -4,12 +4,10 @@
|
|||||||
import { queue } from "$lib/stores/queue";
|
import { queue } from "$lib/stores/queue";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { currentMedia } from "$lib/stores/player";
|
import { currentMedia } from "$lib/stores/player";
|
||||||
import { toast } from "$lib/stores/toast";
|
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import DownloadButton from "./DownloadButton.svelte";
|
import DownloadButton from "./DownloadButton.svelte";
|
||||||
import Portal from "$lib/components/Portal.svelte";
|
import Portal from "$lib/components/Portal.svelte";
|
||||||
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
import { calculateMenuPosition, type MenuPosition } from "$lib/utils/menuPosition";
|
||||||
import { formatDuration } from "$lib/utils/duration";
|
|
||||||
|
|
||||||
/** Queue context for remote transfer - what type of queue is this? */
|
/** Queue context for remote transfer - what type of queue is this? */
|
||||||
export type QueueContext =
|
export type QueueContext =
|
||||||
@ -101,9 +99,8 @@
|
|||||||
|
|
||||||
// Queue will auto-update from Rust backend event
|
// Queue will auto-update from Rust backend event
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
console.error("Failed to play track:", e);
|
||||||
console.error("Failed to play track:", errorMessage);
|
alert(`Failed to play track: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
toast.error(`Failed to play track: ${errorMessage}`, 5000);
|
|
||||||
} finally {
|
} finally {
|
||||||
isPlayingTrack = null;
|
isPlayingTrack = null;
|
||||||
}
|
}
|
||||||
@ -118,6 +115,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ticks?: number): string {
|
||||||
|
if (!ticks) return "-";
|
||||||
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
|
function toggleMenu(trackId: string, buttonElement: HTMLElement, e: Event) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@ -10,31 +10,20 @@
|
|||||||
|
|
||||||
let { session, selected = false, onclick }: Props = $props();
|
let { session, selected = false, onclick }: Props = $props();
|
||||||
|
|
||||||
let imageUrl = $state<string>("");
|
function getImageUrl(): string {
|
||||||
|
if (!session.nowPlayingItem) return "";
|
||||||
// Load image URL asynchronously
|
|
||||||
async function loadImageUrl(): Promise<void> {
|
|
||||||
if (!session.nowPlayingItem) {
|
|
||||||
imageUrl = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
imageUrl = await repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
return repo.getImageUrl(session.nowPlayingItem.id, "Primary", {
|
||||||
maxWidth: 80,
|
maxWidth: 80,
|
||||||
tag: session.nowPlayingItem.primaryImageTag,
|
tag: session.nowPlayingItem.primaryImageTag,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
imageUrl = "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load image when session changes
|
|
||||||
$effect(() => {
|
|
||||||
loadImageUrl();
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatTime(ticks: number): string {
|
function formatTime(ticks: number): string {
|
||||||
const seconds = Math.floor(ticks / 10000000);
|
const seconds = Math.floor(ticks / 10000000);
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@ -46,6 +35,7 @@
|
|||||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageUrl = $derived(getImageUrl());
|
||||||
const playState = $derived(session.playState);
|
const playState = $derived(session.playState);
|
||||||
const nowPlaying = $derived(session.nowPlayingItem);
|
const nowPlaying = $derived(session.nowPlayingItem);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for reloading data when server becomes reachable
|
* Composable for reloading data when server becomes reachable
|
||||||
*
|
*
|
||||||
@ -11,8 +13,7 @@
|
|||||||
* @returns Object with markLoaded function to indicate initial load is complete
|
* @returns Object with markLoaded function to indicate initial load is complete
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```ts
|
||||||
* <script>
|
|
||||||
* const { markLoaded } = useServerReachabilityReload(async () => {
|
* const { markLoaded } = useServerReachabilityReload(async () => {
|
||||||
* await loadData();
|
* await loadData();
|
||||||
* });
|
* });
|
||||||
@ -21,14 +22,25 @@
|
|||||||
* await loadData();
|
* await loadData();
|
||||||
* markLoaded();
|
* markLoaded();
|
||||||
* });
|
* });
|
||||||
* </script>
|
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useServerReachabilityReload(reloadFn: () => void | Promise<void>) {
|
export function useServerReachabilityReload(reloadFn: () => void | Promise<void>) {
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = $state(false);
|
||||||
let previousServerReachable = false;
|
let previousServerReachable = $state(false);
|
||||||
|
|
||||||
|
// Watch for server becoming reachable after initial load
|
||||||
|
$effect(() => {
|
||||||
|
const serverReachable = $isServerReachable;
|
||||||
|
|
||||||
|
if (serverReachable && !previousServerReachable && hasLoadedOnce) {
|
||||||
|
// Server just became reachable and we've done an initial load
|
||||||
|
// Trigger reload to get fresh data
|
||||||
|
reloadFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
previousServerReachable = serverReachable;
|
||||||
|
});
|
||||||
|
|
||||||
// Return an object with reactive getter/setter that can be used in Svelte components
|
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Call this after initial data load to enable server reconnection tracking
|
* Call this after initial data load to enable server reconnection tracking
|
||||||
@ -36,19 +48,5 @@ export function useServerReachabilityReload(reloadFn: () => void | Promise<void>
|
|||||||
markLoaded: () => {
|
markLoaded: () => {
|
||||||
hasLoadedOnce = true;
|
hasLoadedOnce = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this in a $effect block to watch for server reconnection
|
|
||||||
* Pass the current isServerReachable value and this will handle the logic
|
|
||||||
*/
|
|
||||||
checkServerReachability: (isServerReachable: boolean) => {
|
|
||||||
if (isServerReachable && !previousServerReachable && hasLoadedOnce) {
|
|
||||||
// Server just became reachable and we've done an initial load
|
|
||||||
// Trigger reload to get fresh data
|
|
||||||
reloadFn();
|
|
||||||
}
|
|
||||||
|
|
||||||
previousServerReachable = isServerReachable;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Device ID service tests
|
|
||||||
*
|
|
||||||
* Tests the service layer that integrates with the Rust backend.
|
|
||||||
* The Rust backend handles UUID generation and database storage.
|
|
||||||
*
|
|
||||||
* TRACES: UR-009 | DR-011
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { getDeviceId, getDeviceIdSync, clearCache } from "./deviceId";
|
|
||||||
|
|
||||||
// Mock Tauri invoke
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
|
||||||
invoke: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
describe("Device ID Service", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retrieve device ID from backend", async () => {
|
|
||||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
||||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
|
||||||
|
|
||||||
const deviceId = await getDeviceId();
|
|
||||||
|
|
||||||
expect(deviceId).toBe(mockDeviceId);
|
|
||||||
expect(invoke).toHaveBeenCalledWith("device_get_id");
|
|
||||||
expect(invoke).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cache device ID in memory after first call", async () => {
|
|
||||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
||||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
|
||||||
|
|
||||||
const id1 = await getDeviceId();
|
|
||||||
const id2 = await getDeviceId();
|
|
||||||
|
|
||||||
expect(id1).toBe(id2);
|
|
||||||
// Should only invoke backend once due to caching
|
|
||||||
expect(invoke).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return cached device ID synchronously after initialization", async () => {
|
|
||||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
||||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
|
||||||
|
|
||||||
await getDeviceId();
|
|
||||||
const cachedId = getDeviceIdSync();
|
|
||||||
|
|
||||||
expect(cachedId).toBe(mockDeviceId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty string from sync if not yet initialized", () => {
|
|
||||||
const syncId = getDeviceIdSync();
|
|
||||||
expect(syncId).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should throw error when backend fails", async () => {
|
|
||||||
(invoke as any).mockRejectedValue(new Error("Backend error"));
|
|
||||||
|
|
||||||
await expect(getDeviceId()).rejects.toThrow("Failed to initialize device ID");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear cache on logout", async () => {
|
|
||||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
||||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
|
||||||
|
|
||||||
await getDeviceId();
|
|
||||||
expect(getDeviceIdSync()).toBe(mockDeviceId);
|
|
||||||
|
|
||||||
clearCache();
|
|
||||||
expect(getDeviceIdSync()).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call backend again after cache is cleared", async () => {
|
|
||||||
const mockDeviceId = "550e8400-e29b-41d4-a716-446655440000";
|
|
||||||
(invoke as any).mockResolvedValue(mockDeviceId);
|
|
||||||
|
|
||||||
await getDeviceId();
|
|
||||||
clearCache();
|
|
||||||
await getDeviceId();
|
|
||||||
|
|
||||||
// Should call backend twice (once per getDeviceId call)
|
|
||||||
expect(invoke).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Device ID Management Service
|
|
||||||
*
|
|
||||||
* Manages device identification for Jellyfin server communication.
|
|
||||||
* The Rust backend handles UUID generation and persistent storage in the database.
|
|
||||||
* This service provides a simple interface with in-memory caching.
|
|
||||||
*
|
|
||||||
* TRACES: UR-009 | DR-011
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
let cachedDeviceId: string | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create the device ID.
|
|
||||||
* Device ID is a UUID v4 that persists across app restarts.
|
|
||||||
* On first call, the Rust backend generates and stores a new UUID.
|
|
||||||
* On subsequent calls, the stored UUID is retrieved.
|
|
||||||
*
|
|
||||||
* @returns The device ID string (UUID v4)
|
|
||||||
*
|
|
||||||
* TRACES: UR-009 | DR-011
|
|
||||||
*/
|
|
||||||
export async function getDeviceId(): Promise<string> {
|
|
||||||
// Return cached value if available
|
|
||||||
if (cachedDeviceId) {
|
|
||||||
return cachedDeviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Rust backend handles generation and storage atomically
|
|
||||||
const deviceId = await invoke<string>("device_get_id");
|
|
||||||
cachedDeviceId = deviceId;
|
|
||||||
return deviceId;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[deviceId] Failed to get device ID from backend:", e);
|
|
||||||
throw new Error("Failed to initialize device ID: " + String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached device ID synchronously (if available)
|
|
||||||
* This should only be used after initial getDeviceId() call
|
|
||||||
*
|
|
||||||
* @returns The cached device ID, or empty string if not yet initialized
|
|
||||||
*/
|
|
||||||
export function getDeviceIdSync(): string {
|
|
||||||
return cachedDeviceId || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cached device ID (for testing or logout scenarios)
|
|
||||||
*/
|
|
||||||
export function clearCache(): void {
|
|
||||||
cachedDeviceId = null;
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
// Favorites service - Handles toggling favorite status with optimistic updates
|
// Favorites service - Handles toggling favorite status with optimistic updates
|
||||||
// TRACES: UR-017 | DR-021
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
|
||||||
// TRACES: UR-007 | DR-016
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
@ -76,6 +75,51 @@ export async function getCachedImageUrl(
|
|||||||
return serverImageUrl;
|
return serverImageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous version that returns server URL immediately
|
||||||
|
* and triggers background caching. Useful for initial render.
|
||||||
|
*
|
||||||
|
* @param serverUrl - The Jellyfin server base URL
|
||||||
|
* @param itemId - The Jellyfin item ID
|
||||||
|
* @param imageType - The image type (Primary, Backdrop, etc.)
|
||||||
|
* @param options - Image options
|
||||||
|
* @returns The server image URL
|
||||||
|
*/
|
||||||
|
export function getImageUrlSync(
|
||||||
|
serverUrl: string,
|
||||||
|
itemId: string,
|
||||||
|
imageType: string = "Primary",
|
||||||
|
options: {
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
quality?: number;
|
||||||
|
tag?: string;
|
||||||
|
} = {}
|
||||||
|
): string {
|
||||||
|
const tag = options.tag || "default";
|
||||||
|
|
||||||
|
// Build server URL
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
|
||||||
|
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
|
||||||
|
if (options.quality) params.set("quality", options.quality.toString());
|
||||||
|
if (options.tag) params.set("tag", options.tag);
|
||||||
|
|
||||||
|
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
|
||||||
|
|
||||||
|
// Trigger background caching (fire and forget)
|
||||||
|
invoke("thumbnail_save", {
|
||||||
|
itemId,
|
||||||
|
imageType,
|
||||||
|
tag,
|
||||||
|
url: serverImageUrl,
|
||||||
|
}).catch(() => {
|
||||||
|
// Silently fail
|
||||||
|
});
|
||||||
|
|
||||||
|
return serverImageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get thumbnail cache statistics
|
* Get thumbnail cache statistics
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,8 +3,6 @@
|
|||||||
*
|
*
|
||||||
* Handles user interactions with the next episode popup.
|
* Handles user interactions with the next episode popup.
|
||||||
* Backend manages countdown logic and autoplay decisions.
|
* Backend manages countdown logic and autoplay decisions.
|
||||||
*
|
|
||||||
* TRACES: UR-023 | DR-047, DR-048
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
import { cancelAutoplayCountdown, playNextEpisode } from "$lib/api/autoplay";
|
||||||
|
|||||||
@ -1,24 +1,19 @@
|
|||||||
// Playback reporting service
|
// Playback reporting service - syncs to both Jellyfin server and local DB
|
||||||
//
|
//
|
||||||
// Simplified service that delegates all logic to the Rust backend.
|
// This service handles:
|
||||||
// The backend handles:
|
// - Updating local DB (always works, even offline)
|
||||||
// - Local DB updates
|
// - Reporting to Jellyfin server when online
|
||||||
// - Jellyfin server reporting
|
// - Queueing operations for sync when offline
|
||||||
// - Offline queueing (via sync queue)
|
|
||||||
// - Connectivity checks
|
|
||||||
//
|
|
||||||
// TRACES: UR-005, UR-019, UR-025 | DR-028, DR-047
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
import { isServerReachable } from "$lib/stores/connectivity";
|
||||||
|
import { syncService } from "./syncService";
|
||||||
|
import { secondsToTicks } from "$lib/utils/playbackUnits";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report playback start to Jellyfin (or queue if offline)
|
* Report playback start to Jellyfin and local DB
|
||||||
*
|
|
||||||
* The Rust backend handles both local DB updates and server reporting,
|
|
||||||
* automatically queueing for sync if the server is unreachable.
|
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-025 | DR-028
|
|
||||||
*/
|
*/
|
||||||
export async function reportPlaybackStart(
|
export async function reportPlaybackStart(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
@ -26,18 +21,10 @@ export async function reportPlaybackStart(
|
|||||||
contextType: "container" | "single" = "single",
|
contextType: "container" | "single" = "single",
|
||||||
contextId: string | null = null
|
contextId: string | null = null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
const positionTicks = secondsToTicks(positionSeconds);
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
console.log(
|
console.log("reportPlaybackStart - itemId:", itemId, "positionSeconds:", positionSeconds, "context:", contextType, contextId, "userId:", userId);
|
||||||
"[PlaybackReporting] reportPlaybackStart - itemId:",
|
|
||||||
itemId,
|
|
||||||
"positionSeconds:",
|
|
||||||
positionSeconds,
|
|
||||||
"context:",
|
|
||||||
contextType,
|
|
||||||
contextId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update local DB with context (always works, even offline)
|
// Update local DB with context (always works, even offline)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
@ -49,61 +36,59 @@ export async function reportPlaybackStart(
|
|||||||
contextType,
|
contextType,
|
||||||
contextId,
|
contextId,
|
||||||
});
|
});
|
||||||
|
console.log("reportPlaybackStart - Local DB updated with context successfully");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to update playback context:", e);
|
console.error("Failed to update playback context:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connectivity before trying server
|
||||||
|
if (!get(isServerReachable)) {
|
||||||
|
console.log("reportPlaybackStart - Server not reachable, queueing for sync");
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Jellyfin server
|
||||||
|
try {
|
||||||
|
const repo = auth.getRepository();
|
||||||
|
await repo.reportPlaybackStart(itemId, positionTicks);
|
||||||
|
console.log("reportPlaybackStart - Reported to server successfully");
|
||||||
|
|
||||||
|
// Mark as synced
|
||||||
|
if (userId) {
|
||||||
|
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to report playback start to server:", e);
|
||||||
|
// Queue for sync later
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("report_playback_start", itemId, { positionTicks });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Report playback progress to Jellyfin (or queue if offline)
|
* Report playback progress to Jellyfin and local DB
|
||||||
*
|
*
|
||||||
* Note: Progress reports are frequent and are not queued for sync.
|
* Note: Progress reports are frequent, so we don't queue them for sync.
|
||||||
* The final position is captured by reportPlaybackStopped.
|
* The final position is captured by reportPlaybackStopped.
|
||||||
*
|
|
||||||
* TRACES: UR-005 | DR-028
|
|
||||||
*/
|
*/
|
||||||
export async function reportPlaybackProgress(
|
export async function reportPlaybackProgress(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
positionSeconds: number,
|
positionSeconds: number,
|
||||||
_isPaused = false
|
isPaused = false
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
const positionTicks = secondsToTicks(positionSeconds);
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
// Reduce logging for frequent progress updates
|
// Reduce logging for frequent progress updates
|
||||||
if (Math.floor(positionSeconds) % 30 === 0) {
|
if (Math.floor(positionSeconds) % 30 === 0) {
|
||||||
console.log("[PlaybackReporting] reportPlaybackProgress - itemId:", itemId, "position:", positionSeconds);
|
console.log("reportPlaybackProgress - itemId:", itemId, "positionSeconds:", positionSeconds, "isPaused:", isPaused);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local DB only (progress updates are frequent, don't report to server)
|
|
||||||
if (userId) {
|
|
||||||
try {
|
|
||||||
await invoke("storage_update_playback_progress", {
|
|
||||||
userId,
|
|
||||||
itemId,
|
|
||||||
positionTicks,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report playback stopped to Jellyfin (or queue if offline)
|
|
||||||
*
|
|
||||||
* The Rust backend handles both local DB updates and server reporting,
|
|
||||||
* automatically queuing for sync if the server is unreachable.
|
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-025 | DR-028
|
|
||||||
*/
|
|
||||||
export async function reportPlaybackStopped(itemId: string, positionSeconds: number): Promise<void> {
|
|
||||||
const positionTicks = Math.floor(positionSeconds * 10000000);
|
|
||||||
const userId = auth.getUserId();
|
|
||||||
|
|
||||||
console.log("[PlaybackReporting] reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds);
|
|
||||||
|
|
||||||
// Update local DB first (always works, even offline)
|
// Update local DB first (always works, even offline)
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
@ -113,51 +98,126 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
|||||||
positionTicks,
|
positionTicks,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to update local progress:", e);
|
console.error("Failed to update local playback progress:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue for sync to server (the sync service will handle retry logic)
|
// Check connectivity before trying server
|
||||||
if (userId && positionSeconds > 0) {
|
if (!get(isServerReachable)) {
|
||||||
|
// Don't queue progress updates - too frequent. Just store locally.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Jellyfin server (silent failure - progress reports are non-critical)
|
||||||
|
try {
|
||||||
|
const repo = auth.getRepository();
|
||||||
|
await repo.reportPlaybackProgress(itemId, positionTicks);
|
||||||
|
} catch {
|
||||||
|
// Silent failure for progress reports - they're frequent and non-critical
|
||||||
|
// The final position is captured by reportPlaybackStopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report playback stopped to Jellyfin and local DB
|
||||||
|
*/
|
||||||
|
export async function reportPlaybackStopped(
|
||||||
|
itemId: string,
|
||||||
|
positionSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
const positionTicks = secondsToTicks(positionSeconds);
|
||||||
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
|
console.log("reportPlaybackStopped - itemId:", itemId, "positionSeconds:", positionSeconds, "userId:", userId);
|
||||||
|
|
||||||
|
// Update local DB first (always works, even offline)
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await invoke("storage_update_playback_progress", {
|
||||||
|
userId,
|
||||||
|
itemId,
|
||||||
|
positionTicks,
|
||||||
|
});
|
||||||
|
console.log("reportPlaybackStopped - Local DB updated successfully");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update local playback progress:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check connectivity before trying server
|
||||||
|
if (!get(isServerReachable)) {
|
||||||
|
console.log("reportPlaybackStopped - Server not reachable, queueing for sync");
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report to Jellyfin server
|
||||||
try {
|
try {
|
||||||
// Get the repository to check if we should queue
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
await repo.reportPlaybackStopped(itemId, positionTicks);
|
await repo.reportPlaybackStopped(itemId, positionTicks);
|
||||||
|
console.log("reportPlaybackStopped - Reported to server successfully");
|
||||||
|
|
||||||
|
// Mark as synced
|
||||||
|
if (userId) {
|
||||||
|
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
console.error("Failed to report playback stopped to server:", e);
|
||||||
// Server error - could queue, but for now just log
|
// Queue for sync later
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("report_playback_stopped", itemId, { positionTicks });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an item as played (100% progress)
|
* Mark an item as played (100% progress)
|
||||||
*
|
|
||||||
* TRACES: UR-025 | DR-028
|
|
||||||
*/
|
*/
|
||||||
export async function markAsPlayed(itemId: string): Promise<void> {
|
export async function markAsPlayed(itemId: string): Promise<void> {
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|
||||||
console.log("[PlaybackReporting] markAsPlayed - itemId:", itemId);
|
console.log("markAsPlayed - itemId:", itemId, "userId:", userId);
|
||||||
|
|
||||||
// Update local DB first
|
// Update local DB first
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
await invoke("storage_mark_played", { userId, itemId });
|
await invoke("storage_mark_played", { userId, itemId });
|
||||||
|
console.log("markAsPlayed - Local DB updated successfully");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to mark as played in local DB:", e);
|
console.error("Failed to mark as played in local DB:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to report to server via repository (handles queuing internally)
|
// Check connectivity before trying server
|
||||||
|
if (!get(isServerReachable)) {
|
||||||
|
console.log("markAsPlayed - Server not reachable, queueing for sync");
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("mark_played", itemId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Jellyfin, we need to get the item's runtime and report stopped at 100%
|
||||||
try {
|
try {
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const item = await repo.getItem(itemId);
|
const item = await repo.getItem(itemId);
|
||||||
|
|
||||||
if (item.runTimeTicks) {
|
if (item.runTimeTicks) {
|
||||||
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
|
await repo.reportPlaybackStopped(itemId, item.runTimeTicks);
|
||||||
|
console.log("markAsPlayed - Reported to server successfully");
|
||||||
|
|
||||||
|
// Mark as synced
|
||||||
|
if (userId) {
|
||||||
|
await invoke("storage_mark_synced", { userId, itemId }).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to report as played:", e);
|
console.error("Failed to mark as played on server:", e);
|
||||||
|
// Queue for sync later
|
||||||
|
if (userId) {
|
||||||
|
await syncService.queueMutation("mark_played", itemId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Player Events Service tests
|
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { isPlayerEventsInitialized, cleanupPlayerEvents } from "./playerEvents";
|
|
||||||
|
|
||||||
// Mock Tauri
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
|
||||||
listen: vi.fn(async (event, handler) => {
|
|
||||||
return () => {}; // Return unlisten function
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
|
||||||
invoke: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock stores
|
|
||||||
vi.mock("$lib/stores/player", () => ({
|
|
||||||
player: {
|
|
||||||
updatePosition: vi.fn(),
|
|
||||||
setPlaying: vi.fn(),
|
|
||||||
setPaused: vi.fn(),
|
|
||||||
setLoading: vi.fn(),
|
|
||||||
setIdle: vi.fn(),
|
|
||||||
setError: vi.fn(),
|
|
||||||
setVolume: vi.fn(),
|
|
||||||
setMuted: vi.fn(),
|
|
||||||
},
|
|
||||||
playbackPosition: { subscribe: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/queue", () => ({
|
|
||||||
queue: { subscribe: vi.fn() },
|
|
||||||
currentQueueItem: { subscribe: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/playbackMode", () => ({
|
|
||||||
playbackMode: { setMode: vi.fn(), initializeSessionMonitoring: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/sleepTimer", () => ({
|
|
||||||
sleepTimer: { set: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/stores/nextEpisode", () => ({
|
|
||||||
nextEpisode: {
|
|
||||||
showPopup: vi.fn(),
|
|
||||||
updateCountdown: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("$lib/services/preload", () => ({
|
|
||||||
preloadUpcomingTracks: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Player Events Service", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should initialize player event listener", async () => {
|
|
||||||
const { initPlayerEvents } = await import("./playerEvents");
|
|
||||||
await initPlayerEvents();
|
|
||||||
|
|
||||||
expect(isPlayerEventsInitialized()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should prevent duplicate initialization", async () => {
|
|
||||||
const { initPlayerEvents } = await import("./playerEvents");
|
|
||||||
|
|
||||||
await initPlayerEvents();
|
|
||||||
const consoleSpy = vi.spyOn(console, "warn");
|
|
||||||
|
|
||||||
await initPlayerEvents();
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("already initialized"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cleanup event listeners", async () => {
|
|
||||||
const { initPlayerEvents } = await import("./playerEvents");
|
|
||||||
|
|
||||||
await initPlayerEvents();
|
|
||||||
expect(isPlayerEventsInitialized()).toBe(true);
|
|
||||||
|
|
||||||
cleanupPlayerEvents();
|
|
||||||
expect(isPlayerEventsInitialized()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle player event initialization errors", async () => {
|
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
|
||||||
(listen as any).mockRejectedValueOnce(new Error("Event setup failed"));
|
|
||||||
|
|
||||||
const { initPlayerEvents } = await import("./playerEvents");
|
|
||||||
const consoleSpy = vi.spyOn(console, "error");
|
|
||||||
|
|
||||||
await initPlayerEvents();
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to initialize player events"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -4,8 +4,6 @@
|
|||||||
* Listens for Tauri events from the player backend and updates the
|
* Listens for Tauri events from the player backend and updates the
|
||||||
* frontend stores accordingly. This enables push-based updates instead
|
* frontend stores accordingly. This enables push-based updates instead
|
||||||
* of polling.
|
* of polling.
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
@ -14,16 +12,13 @@ import { player, playbackPosition } from "$lib/stores/player";
|
|||||||
import { queue, currentQueueItem } from "$lib/stores/queue";
|
import { queue, currentQueueItem } from "$lib/stores/queue";
|
||||||
import { playbackMode } from "$lib/stores/playbackMode";
|
import { playbackMode } from "$lib/stores/playbackMode";
|
||||||
import { sleepTimer } from "$lib/stores/sleepTimer";
|
import { sleepTimer } from "$lib/stores/sleepTimer";
|
||||||
import { nextEpisode } from "$lib/stores/nextEpisode";
|
import { handleEpisodeEnded as showNextEpisodePopup } from "$lib/services/nextEpisodeService";
|
||||||
import { preloadUpcomingTracks } from "$lib/services/preload";
|
import { preloadUpcomingTracks } from "$lib/services/preload";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event types emitted by the player backend.
|
* Event types emitted by the player backend.
|
||||||
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
|
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
|
||||||
*/
|
*/
|
||||||
export type PlayerStatusEvent =
|
export type PlayerStatusEvent =
|
||||||
| { type: "position_update"; position: number; duration: number }
|
| { type: "position_update"; position: number; duration: number }
|
||||||
@ -156,8 +151,6 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle position update events.
|
* Handle position update events.
|
||||||
*
|
|
||||||
* TRACES: UR-005, UR-025 | DR-028
|
|
||||||
*/
|
*/
|
||||||
function handlePositionUpdate(position: number, duration: number): void {
|
function handlePositionUpdate(position: number, duration: number): void {
|
||||||
player.updatePosition(position, duration);
|
player.updatePosition(position, duration);
|
||||||
@ -166,10 +159,8 @@ function handlePositionUpdate(position: number, duration: number): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle state change events.
|
* Handle state change events.
|
||||||
*
|
|
||||||
* TRACES: UR-005 | DR-001
|
|
||||||
*/
|
*/
|
||||||
async function handleStateChanged(state: string, _mediaId: string | null): Promise<void> {
|
function handleStateChanged(state: string, mediaId: string | null): void {
|
||||||
// Get current media from queue store
|
// Get current media from queue store
|
||||||
const currentItem = get(currentQueueItem);
|
const currentItem = get(currentQueueItem);
|
||||||
|
|
||||||
@ -190,9 +181,8 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
player.setPlaying(currentItem, 0, initialDuration);
|
player.setPlaying(currentItem, 0, initialDuration);
|
||||||
|
|
||||||
// Trigger preloading of upcoming tracks in the background
|
// Trigger preloading of upcoming tracks in the background
|
||||||
preloadUpcomingTracks().catch((e) => {
|
preloadUpcomingTracks().catch(() => {
|
||||||
// Preload failures are non-critical, already logged in the service
|
// Preload failures are non-critical, already logged in the service
|
||||||
console.debug("[playerEvents] Preload failed (non-critical):", e);
|
|
||||||
});
|
});
|
||||||
} else if (state === "paused" && currentItem) {
|
} else if (state === "paused" && currentItem) {
|
||||||
// Keep current position from store
|
// Keep current position from store
|
||||||
@ -202,9 +192,6 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
} else if (state === "loading" && currentItem) {
|
} else if (state === "loading" && currentItem) {
|
||||||
player.setLoading(currentItem);
|
player.setLoading(currentItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update queue status on state change
|
|
||||||
await updateQueueStatus();
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "idle":
|
case "idle":
|
||||||
@ -216,37 +203,10 @@ async function handleStateChanged(state: string, _mediaId: string | null): Promi
|
|||||||
console.log("Setting playback mode to idle");
|
console.log("Setting playback mode to idle");
|
||||||
playbackMode.setMode("idle");
|
playbackMode.setMode("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update queue status on state change
|
|
||||||
await updateQueueStatus();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update queue status from backend.
|
|
||||||
* Called on state changes instead of polling.
|
|
||||||
*/
|
|
||||||
async function updateQueueStatus(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const queueStatus = await invoke<{
|
|
||||||
hasNext: boolean;
|
|
||||||
hasPrevious: boolean;
|
|
||||||
shuffle: boolean;
|
|
||||||
repeat: string;
|
|
||||||
}>("player_get_queue_status");
|
|
||||||
|
|
||||||
// Import appState stores dynamically to avoid circular imports
|
|
||||||
const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState");
|
|
||||||
hasNext.set(queueStatus.hasNext);
|
|
||||||
hasPrevious.set(queueStatus.hasPrevious);
|
|
||||||
shuffle.set(queueStatus.shuffle);
|
|
||||||
repeat.set(queueStatus.repeat as "off" | "all" | "one");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[playerEvents] Failed to update queue status:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle media loaded event.
|
* Handle media loaded event.
|
||||||
*/
|
*/
|
||||||
@ -259,8 +219,6 @@ function handleMediaLoaded(duration: number): void {
|
|||||||
/**
|
/**
|
||||||
* Handle playback ended event.
|
* Handle playback ended event.
|
||||||
* Calls backend to handle autoplay decisions (sleep timer, queue advance, episode popup).
|
* Calls backend to handle autoplay decisions (sleep timer, queue advance, episode popup).
|
||||||
*
|
|
||||||
* TRACES: UR-023, UR-026 | DR-047, DR-029
|
|
||||||
*/
|
*/
|
||||||
async function handlePlaybackEnded(): Promise<void> {
|
async function handlePlaybackEnded(): Promise<void> {
|
||||||
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
|
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
|
||||||
@ -276,28 +234,18 @@ async function handlePlaybackEnded(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Handle error events.
|
* Handle error events.
|
||||||
*/
|
*/
|
||||||
async function handleError(message: string, recoverable: boolean): Promise<void> {
|
function handleError(message: string, recoverable: boolean): void {
|
||||||
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
||||||
player.setError(message);
|
player.setError(message);
|
||||||
|
|
||||||
// Stop backend player to prevent orphaned playback
|
if (!recoverable) {
|
||||||
// This also reports playback stopped to Jellyfin server
|
// For non-recoverable errors, return to idle
|
||||||
try {
|
|
||||||
await invoke("player_stop");
|
|
||||||
console.log("Backend player stopped after error");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to stop player after error:", e);
|
|
||||||
// Continue with state cleanup even if stop fails
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always return to idle after an error
|
|
||||||
player.setIdle();
|
player.setIdle();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle sleep timer changed event.
|
* Handle sleep timer changed event.
|
||||||
*
|
|
||||||
* TRACES: UR-026 | DR-029
|
|
||||||
*/
|
*/
|
||||||
function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number): void {
|
function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number): void {
|
||||||
sleepTimer.set({ mode, remainingSeconds });
|
sleepTimer.set({ mode, remainingSeconds });
|
||||||
@ -305,17 +253,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle show next episode popup event.
|
* Handle show next episode popup event.
|
||||||
*
|
|
||||||
* TRACES: UR-023 | DR-047, DR-048
|
|
||||||
*/
|
*/
|
||||||
function handleShowNextEpisodePopup(
|
function handleShowNextEpisodePopup(
|
||||||
currentEpisodeItem: MediaItem,
|
currentEpisode: MediaItem,
|
||||||
nextEpisodeItem: MediaItem,
|
nextEpisode: MediaItem,
|
||||||
countdownSeconds: number,
|
countdownSeconds: number,
|
||||||
autoAdvance: boolean
|
autoAdvance: boolean
|
||||||
): void {
|
): void {
|
||||||
// Update next episode store to show popup
|
// Update next episode store to show popup
|
||||||
nextEpisode.showPopup(currentEpisodeItem, nextEpisodeItem, countdownSeconds, autoAdvance);
|
nextEpisode.showPopup(currentEpisode, nextEpisode, countdownSeconds, autoAdvance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Smart preloading service for upcoming tracks
|
* Smart preloading service for upcoming tracks
|
||||||
* Automatically queues downloads for the next few tracks in the queue
|
* Automatically queues downloads for the next few tracks in the queue
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-011 | DR-006, DR-015
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
// Sync service - manages offline mutation queueing
|
// Sync service - processes queued mutations when connectivity is restored
|
||||||
//
|
//
|
||||||
// Simplified service that coordinates with the Rust backend.
|
// This service handles:
|
||||||
// The Rust backend handles sync queue persistence and processing logic.
|
// - Queueing mutations (favorites, playback progress) when offline
|
||||||
// This service provides a thin TypeScript API for queuing mutations.
|
// - Processing queued mutations when connectivity is restored
|
||||||
//
|
// - Retry with exponential backoff for failed operations
|
||||||
// TRACES: UR-002, UR-017, UR-025 | DR-014
|
|
||||||
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { isServerReachable, connectivity } from "$lib/stores/connectivity";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
|
|
||||||
// Types matching Rust structs
|
// Types matching Rust structs
|
||||||
@ -24,24 +25,62 @@ export interface SyncQueueItem {
|
|||||||
|
|
||||||
export type SyncOperation =
|
export type SyncOperation =
|
||||||
| "mark_played"
|
| "mark_played"
|
||||||
|
| "mark_unplayed"
|
||||||
| "mark_favorite"
|
| "mark_favorite"
|
||||||
| "unmark_favorite"
|
| "unmark_favorite"
|
||||||
| "update_progress"
|
| "update_progress"
|
||||||
| "report_playback_start"
|
| "report_playback_start"
|
||||||
| "report_playback_stopped";
|
| "report_playback_stopped";
|
||||||
|
|
||||||
/**
|
// Maximum retries before giving up on an operation
|
||||||
* Simplified sync service - handles offline mutation queueing
|
const MAX_RETRIES = 5;
|
||||||
*
|
|
||||||
* The Rust backend maintains the sync queue in SQLite and is responsible
|
// Delay between sync attempts (exponential backoff)
|
||||||
* for processing queued items. This service provides a TypeScript API
|
const BASE_RETRY_DELAY_MS = 1000;
|
||||||
* for queueing and managing sync operations.
|
|
||||||
*/
|
// Batch size for processing queue
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
|
|
||||||
class SyncService {
|
class SyncService {
|
||||||
|
private processing = false;
|
||||||
|
private unsubscribeConnectivity: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the sync service - listens for connectivity changes
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
if (this.unsubscribeConnectivity) {
|
||||||
|
return; // Already started
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[SyncService] Starting...");
|
||||||
|
|
||||||
|
// Listen for connectivity changes
|
||||||
|
this.unsubscribeConnectivity = isServerReachable.subscribe((reachable) => {
|
||||||
|
if (reachable && !this.processing) {
|
||||||
|
console.log("[SyncService] Server became reachable, processing queue...");
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process queue on startup if online
|
||||||
|
if (get(isServerReachable)) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the sync service
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (this.unsubscribeConnectivity) {
|
||||||
|
this.unsubscribeConnectivity();
|
||||||
|
this.unsubscribeConnectivity = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a mutation for sync to server
|
* Queue a mutation for sync to server
|
||||||
*
|
|
||||||
* TRACES: UR-017, UR-025 | DR-014
|
|
||||||
*/
|
*/
|
||||||
async queueMutation(
|
async queueMutation(
|
||||||
operation: SyncOperation,
|
operation: SyncOperation,
|
||||||
@ -61,15 +100,20 @@ class SyncService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
console.log(`[SyncService] Queued ${operation} for item ${itemId}, id: ${id}`);
|
||||||
|
|
||||||
|
// Try to process immediately if online
|
||||||
|
if (get(isServerReachable) && !this.processing) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a favorite toggle
|
* Queue a favorite toggle
|
||||||
* Also updates local state immediately
|
|
||||||
*/
|
*/
|
||||||
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
async queueFavorite(itemId: string, isFavorite: boolean): Promise<number> {
|
||||||
// Update local state first
|
// Also update local state
|
||||||
await invoke("storage_toggle_favorite", {
|
await invoke("storage_toggle_favorite", {
|
||||||
userId: auth.getUserId(),
|
userId: auth.getUserId(),
|
||||||
itemId,
|
itemId,
|
||||||
@ -84,13 +128,12 @@ class SyncService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue playback progress update
|
* Queue playback progress update
|
||||||
* Also updates local state immediately
|
|
||||||
*/
|
*/
|
||||||
async queuePlaybackProgress(
|
async queuePlaybackProgress(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
positionTicks: number
|
positionTicks: number
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
// Update local state first
|
// Also update local state
|
||||||
await invoke("storage_update_playback_progress", {
|
await invoke("storage_update_playback_progress", {
|
||||||
userId: auth.getUserId(),
|
userId: auth.getUserId(),
|
||||||
itemId,
|
itemId,
|
||||||
@ -102,10 +145,9 @@ class SyncService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue mark as played
|
* Queue mark as played
|
||||||
* Also updates local state immediately
|
|
||||||
*/
|
*/
|
||||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||||
// Update local state first
|
// Also update local state
|
||||||
await invoke("storage_mark_played", {
|
await invoke("storage_mark_played", {
|
||||||
userId: auth.getUserId(),
|
userId: auth.getUserId(),
|
||||||
itemId,
|
itemId,
|
||||||
@ -127,18 +169,167 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get pending sync items (for debugging/monitoring)
|
* Process the sync queue
|
||||||
*/
|
*/
|
||||||
async getPending(limit?: number): Promise<SyncQueueItem[]> {
|
async processQueue(): Promise<void> {
|
||||||
const userId = auth.getUserId();
|
if (this.processing) {
|
||||||
if (!userId) {
|
console.log("[SyncService] Already processing queue");
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return invoke<SyncQueueItem[]>("sync_get_pending", {
|
const userId = auth.getUserId();
|
||||||
|
if (!userId) {
|
||||||
|
console.log("[SyncService] Not authenticated, skipping queue processing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!get(isServerReachable)) {
|
||||||
|
console.log("[SyncService] Server not reachable, skipping queue processing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true;
|
||||||
|
console.log("[SyncService] Processing sync queue...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get pending items
|
||||||
|
const items = await invoke<SyncQueueItem[]>("sync_get_pending", {
|
||||||
userId,
|
userId,
|
||||||
limit,
|
limit: BATCH_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
console.log("[SyncService] No pending items in queue");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SyncService] Processing ${items.length} queued items`);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Check connectivity before each item
|
||||||
|
if (!get(isServerReachable)) {
|
||||||
|
console.log("[SyncService] Lost connectivity, stopping queue processing");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've exceeded retries
|
||||||
|
if (item.retryCount >= MAX_RETRIES) {
|
||||||
|
console.warn(
|
||||||
|
`[SyncService] Item ${item.id} exceeded max retries, marking as failed`
|
||||||
|
);
|
||||||
|
await invoke("sync_mark_failed", {
|
||||||
|
id: item.id,
|
||||||
|
error: "Exceeded maximum retry attempts",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.processItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are more items to process
|
||||||
|
const remaining = await this.getPendingCount();
|
||||||
|
if (remaining > 0 && get(isServerReachable)) {
|
||||||
|
// Process next batch after a short delay
|
||||||
|
setTimeout(() => this.processQueue(), 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[SyncService] Error processing queue:", error);
|
||||||
|
} finally {
|
||||||
|
this.processing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single sync queue item
|
||||||
|
*/
|
||||||
|
private async processItem(item: SyncQueueItem): Promise<void> {
|
||||||
|
console.log(`[SyncService] Processing item ${item.id}: ${item.operation}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mark as processing
|
||||||
|
await invoke("sync_mark_processing", { id: item.id });
|
||||||
|
|
||||||
|
// Get repository for API calls
|
||||||
|
const repo = auth.getRepository();
|
||||||
|
|
||||||
|
// Execute the operation
|
||||||
|
switch (item.operation) {
|
||||||
|
case "mark_favorite":
|
||||||
|
if (item.itemId) {
|
||||||
|
await repo.markFavorite(item.itemId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unmark_favorite":
|
||||||
|
if (item.itemId) {
|
||||||
|
await repo.unmarkFavorite(item.itemId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "update_progress":
|
||||||
|
if (item.itemId && item.payload) {
|
||||||
|
const payload = JSON.parse(item.payload);
|
||||||
|
await repo.reportPlaybackProgress(item.itemId, payload.positionTicks);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mark_played":
|
||||||
|
if (item.itemId) {
|
||||||
|
// Jellyfin doesn't have a direct "mark played" endpoint,
|
||||||
|
// we report playback stopped at 100%
|
||||||
|
const itemData = await repo.getItem(item.itemId);
|
||||||
|
if (itemData.runTimeTicks) {
|
||||||
|
await repo.reportPlaybackStopped(item.itemId, itemData.runTimeTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "report_playback_start":
|
||||||
|
if (item.itemId && item.payload) {
|
||||||
|
const payload = JSON.parse(item.payload);
|
||||||
|
await repo.reportPlaybackStart(item.itemId, payload.positionTicks);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "report_playback_stopped":
|
||||||
|
if (item.itemId && item.payload) {
|
||||||
|
const payload = JSON.parse(item.payload);
|
||||||
|
await repo.reportPlaybackStopped(item.itemId, payload.positionTicks);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`[SyncService] Unknown operation: ${item.operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
await invoke("sync_mark_completed", { id: item.id });
|
||||||
|
|
||||||
|
// Also mark local data as synced
|
||||||
|
if (item.itemId) {
|
||||||
|
await invoke("storage_mark_synced", {
|
||||||
|
userId: item.userId,
|
||||||
|
itemId: item.itemId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SyncService] Successfully processed item ${item.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SyncService] Failed to process item ${item.id}:`, error);
|
||||||
|
|
||||||
|
// Calculate retry delay with exponential backoff
|
||||||
|
const retryDelay = BASE_RETRY_DELAY_MS * Math.pow(2, item.retryCount);
|
||||||
|
|
||||||
|
// Mark as failed
|
||||||
|
await invoke("sync_mark_failed", {
|
||||||
|
id: item.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait before continuing (gives server time to recover if overloaded)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.min(retryDelay, 10000)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -152,8 +343,6 @@ class SyncService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all sync operations for the current user (called during logout)
|
* Clear all sync operations for the current user (called during logout)
|
||||||
*
|
|
||||||
* TRACES: UR-017 | DR-014
|
|
||||||
*/
|
*/
|
||||||
async clearUser(): Promise<void> {
|
async clearUser(): Promise<void> {
|
||||||
const userId = auth.getUserId();
|
const userId = auth.getUserId();
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
// Application-wide UI state store
|
|
||||||
// TRACES: UR-005 | DR-005, DR-009
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
// App-wide state (root layout)
|
|
||||||
export const isInitialized = writable(false);
|
|
||||||
export const pendingSyncCount = writable(0);
|
|
||||||
export const isAndroid = writable(false);
|
|
||||||
export const shuffle = writable(false);
|
|
||||||
export const repeat = writable<'off' | 'all' | 'one'>('off');
|
|
||||||
export const hasNext = writable(false);
|
|
||||||
export const hasPrevious = writable(false);
|
|
||||||
export const showSleepTimerModal = writable(false);
|
|
||||||
|
|
||||||
// Library-specific state
|
|
||||||
export const librarySearchQuery = writable("");
|
|
||||||
export const libraryShowFullPlayer = writable(false);
|
|
||||||
export const libraryShowOverflowMenu = writable(false);
|
|
||||||
export const libraryShowSleepTimerModal = writable(false);
|
|
||||||
@ -2,8 +2,6 @@
|
|||||||
//
|
//
|
||||||
// All business logic (session management, verification, credential storage) is handled by Rust.
|
// All business logic (session management, verification, credential storage) is handled by Rust.
|
||||||
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
|
// This file is a thin Svelte store wrapper that calls Rust commands and listens to events.
|
||||||
//
|
|
||||||
// TRACES: UR-009, UR-012 | IR-009, IR-014
|
|
||||||
|
|
||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@ -11,7 +9,6 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { RepositoryClient } from "$lib/api/repository-client";
|
import { RepositoryClient } from "$lib/api/repository-client";
|
||||||
import type { User, AuthResult } from "$lib/api/types";
|
import type { User, AuthResult } from "$lib/api/types";
|
||||||
import { connectivity } from "./connectivity";
|
import { connectivity } from "./connectivity";
|
||||||
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@ -71,11 +68,6 @@ function createAuthStore() {
|
|||||||
// RepositoryClient provides cache-first access with automatic background refresh via Rust
|
// RepositoryClient provides cache-first access with automatic background refresh via Rust
|
||||||
let repository: RepositoryClient | null = null;
|
let repository: RepositoryClient | null = null;
|
||||||
|
|
||||||
// Store unlisten functions for cleanup
|
|
||||||
let unlistenSessionVerified: (() => void) | null = null;
|
|
||||||
let unlistenNeedsReauth: (() => void) | null = null;
|
|
||||||
let unlistenNetworkError: (() => void) | null = null;
|
|
||||||
|
|
||||||
function getRepository(): RepositoryClient {
|
function getRepository(): RepositoryClient {
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
throw new Error("Not connected to a server");
|
throw new Error("Not connected to a server");
|
||||||
@ -83,15 +75,9 @@ function createAuthStore() {
|
|||||||
return repository;
|
return repository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Listen to auth events from Rust
|
||||||
* Initialize event listeners from Rust backend.
|
if (typeof window !== "undefined") {
|
||||||
* These should be called once during app initialization.
|
listen<{ user: User }>("auth:session-verified", (event) => {
|
||||||
*/
|
|
||||||
async function initializeEventListeners(): Promise<void> {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
unlistenSessionVerified = await listen<{ user: User }>("auth:session-verified", (event) => {
|
|
||||||
console.log("[Auth] Session verified:", event.payload.user.name);
|
console.log("[Auth] Session verified:", event.payload.user.name);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@ -101,12 +87,8 @@ function createAuthStore() {
|
|||||||
user: event.payload.user,
|
user: event.payload.user,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.error("[Auth] Failed to listen to session-verified event:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
||||||
unlistenNeedsReauth = await listen<{ reason: string }>("auth:needs-reauth", (event) => {
|
|
||||||
console.log("[Auth] Session needs re-authentication:", event.payload.reason);
|
console.log("[Auth] Session needs re-authentication:", event.payload.reason);
|
||||||
update((s) => ({
|
update((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@ -116,38 +98,12 @@ function createAuthStore() {
|
|||||||
error: event.payload.reason,
|
error: event.payload.reason,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.error("[Auth] Failed to listen to needs-reauth event:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
listen<{ message: string }>("auth:network-error", (event) => {
|
||||||
unlistenNetworkError = await listen<{ message: string }>("auth:network-error", (event) => {
|
|
||||||
console.log("[Auth] Network error during verification:", event.payload.message);
|
console.log("[Auth] Network error during verification:", event.payload.message);
|
||||||
// Network errors don't trigger re-auth - just log them
|
// Network errors don't trigger re-auth - just log them
|
||||||
update((s) => ({ ...s, isVerifying: false }));
|
update((s) => ({ ...s, isVerifying: false }));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
|
||||||
console.error("[Auth] Failed to listen to network-error event:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup event listeners.
|
|
||||||
* Should be called when the app is destroyed.
|
|
||||||
*/
|
|
||||||
function cleanupEventListeners(): void {
|
|
||||||
if (unlistenSessionVerified) {
|
|
||||||
unlistenSessionVerified();
|
|
||||||
unlistenSessionVerified = null;
|
|
||||||
}
|
|
||||||
if (unlistenNeedsReauth) {
|
|
||||||
unlistenNeedsReauth();
|
|
||||||
unlistenNeedsReauth = null;
|
|
||||||
}
|
|
||||||
if (unlistenNetworkError) {
|
|
||||||
unlistenNetworkError();
|
|
||||||
unlistenNetworkError = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -155,9 +111,6 @@ function createAuthStore() {
|
|||||||
* This function does NOT require network access - session is restored immediately.
|
* This function does NOT require network access - session is restored immediately.
|
||||||
*/
|
*/
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
// Initialize event listeners first
|
|
||||||
await initializeEventListeners();
|
|
||||||
|
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -189,7 +142,7 @@ function createAuthStore() {
|
|||||||
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
|
await repository.create(session.serverUrl, session.userId, session.accessToken, session.serverId);
|
||||||
|
|
||||||
// Configure Jellyfin client in Rust player for automatic playback reporting
|
// Configure Jellyfin client in Rust player for automatic playback reporting
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||||
try {
|
try {
|
||||||
console.log("[Auth] Configuring Rust player with restored session...");
|
console.log("[Auth] Configuring Rust player with restored session...");
|
||||||
await invoke("player_configure_jellyfin", {
|
await invoke("player_configure_jellyfin", {
|
||||||
@ -230,8 +183,7 @@ function createAuthStore() {
|
|||||||
|
|
||||||
// Start background session verification
|
// Start background session verification
|
||||||
try {
|
try {
|
||||||
const verifyDeviceId = await getDeviceId();
|
await invoke("auth_start_verification", { deviceId });
|
||||||
await invoke("auth_start_verification", { deviceId: verifyDeviceId });
|
|
||||||
console.log("[Auth] Background verification started");
|
console.log("[Auth] Background verification started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to start verification:", error);
|
console.error("[Auth] Failed to start verification:", error);
|
||||||
@ -265,8 +217,6 @@ function createAuthStore() {
|
|||||||
/**
|
/**
|
||||||
* Connect to a Jellyfin server and retrieve server info.
|
* Connect to a Jellyfin server and retrieve server info.
|
||||||
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
|
* Rust will normalize the URL (add https:// if missing, remove trailing slash).
|
||||||
*
|
|
||||||
* TRACES: UR-009 | IR-009
|
|
||||||
*/
|
*/
|
||||||
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
|
async function connectToServer(serverUrl: string): Promise<ServerInfo> {
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||||
@ -292,14 +242,12 @@ function createAuthStore() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with username and password.
|
* Login with username and password.
|
||||||
*
|
|
||||||
* TRACES: UR-009, UR-012 | IR-009, IR-014
|
|
||||||
*/
|
*/
|
||||||
async function login(username: string, password: string, serverUrl: string, serverName: string) {
|
async function login(username: string, password: string, serverUrl: string, serverName: string) {
|
||||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||||
console.log("[Auth] Logging in as:", username);
|
console.log("[Auth] Logging in as:", username);
|
||||||
|
|
||||||
const authResult = await invoke<AuthResult>("auth_login", {
|
const authResult = await invoke<AuthResult>("auth_login", {
|
||||||
@ -351,12 +299,11 @@ function createAuthStore() {
|
|||||||
|
|
||||||
// Configure Rust player
|
// Configure Rust player
|
||||||
try {
|
try {
|
||||||
const playerDeviceId = await getDeviceId();
|
|
||||||
await invoke("player_configure_jellyfin", {
|
await invoke("player_configure_jellyfin", {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
accessToken: authResult.accessToken,
|
accessToken: authResult.accessToken,
|
||||||
userId: authResult.user.id,
|
userId: authResult.user.id,
|
||||||
deviceId: playerDeviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
console.log("[Auth] Rust player configured for playback reporting");
|
console.log("[Auth] Rust player configured for playback reporting");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -379,8 +326,7 @@ function createAuthStore() {
|
|||||||
|
|
||||||
// Start background verification
|
// Start background verification
|
||||||
try {
|
try {
|
||||||
const verifyDeviceId = await getDeviceId();
|
await invoke("auth_start_verification", { deviceId });
|
||||||
await invoke("auth_start_verification", { deviceId: verifyDeviceId });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to start verification:", error);
|
console.error("[Auth] Failed to start verification:", error);
|
||||||
}
|
}
|
||||||
@ -401,7 +347,7 @@ function createAuthStore() {
|
|||||||
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
|
update((s) => ({ ...s, isLoading: true, error: null, needsReauth: false }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||||
console.log("[Auth] Re-authenticating...");
|
console.log("[Auth] Re-authenticating...");
|
||||||
|
|
||||||
const authResult = await invoke<AuthResult>("auth_reauthenticate", {
|
const authResult = await invoke<AuthResult>("auth_reauthenticate", {
|
||||||
@ -430,12 +376,11 @@ function createAuthStore() {
|
|||||||
|
|
||||||
// Reconfigure player
|
// Reconfigure player
|
||||||
try {
|
try {
|
||||||
const playerDeviceId = await getDeviceId();
|
|
||||||
await invoke("player_configure_jellyfin", {
|
await invoke("player_configure_jellyfin", {
|
||||||
serverUrl: repository ? await getCurrentSessionServerUrl() : "",
|
serverUrl: repository ? await getCurrentSessionServerUrl() : "",
|
||||||
accessToken: authResult.accessToken,
|
accessToken: authResult.accessToken,
|
||||||
userId: authResult.user.id,
|
userId: authResult.user.id,
|
||||||
deviceId: playerDeviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Failed to reconfigure player:", error);
|
console.error("[Auth] Failed to reconfigure player:", error);
|
||||||
@ -462,14 +407,12 @@ function createAuthStore() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout and clear session.
|
* Logout and clear session.
|
||||||
*
|
|
||||||
* TRACES: UR-012 | IR-014
|
|
||||||
*/
|
*/
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
const session = await invoke<Session | null>("auth_get_session");
|
const session = await invoke<Session | null>("auth_get_session");
|
||||||
if (session) {
|
if (session) {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||||
await invoke("auth_logout", {
|
await invoke("auth_logout", {
|
||||||
serverUrl: session.serverUrl,
|
serverUrl: session.serverUrl,
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
@ -502,13 +445,9 @@ function createAuthStore() {
|
|||||||
isVerifying: false,
|
isVerifying: false,
|
||||||
sessionVerified: false,
|
sessionVerified: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear device ID cache on logout
|
|
||||||
clearDeviceIdCache();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Auth] Logout error (continuing anyway):", error);
|
console.error("[Auth] Logout error (continuing anyway):", error);
|
||||||
set(initialState);
|
set(initialState);
|
||||||
clearDeviceIdCache();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,7 +499,7 @@ function createAuthStore() {
|
|||||||
*/
|
*/
|
||||||
async function retryVerification() {
|
async function retryVerification() {
|
||||||
try {
|
try {
|
||||||
const deviceId = await getDeviceId();
|
const deviceId = localStorage.getItem("jellytau_device_id") || "";
|
||||||
console.log("[Auth] Retrying session verification after reconnection");
|
console.log("[Auth] Retrying session verification after reconnection");
|
||||||
await invoke("auth_start_verification", { deviceId });
|
await invoke("auth_start_verification", { deviceId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -581,7 +520,6 @@ function createAuthStore() {
|
|||||||
getUserId,
|
getUserId,
|
||||||
getServerUrl,
|
getServerUrl,
|
||||||
retryVerification,
|
retryVerification,
|
||||||
cleanupEventListeners,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
//
|
//
|
||||||
// Simplified wrapper over Rust connectivity monitor.
|
// Simplified wrapper over Rust connectivity monitor.
|
||||||
// The Rust backend handles all polling, reachability checks, and adaptive intervals.
|
// The Rust backend handles all polling, reachability checks, and adaptive intervals.
|
||||||
// TRACES: UR-002 | DR-013
|
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Tests for downloads store
|
|
||||||
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 | UT-010, UT-024
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Download manager state store
|
|
||||||
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
|
|
||||||
import { writable, derived, get } from 'svelte/store';
|
import { writable, derived, get } from 'svelte/store';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
@ -77,21 +75,8 @@ function createDownloadsStore() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prevent concurrent refresh calls (race condition protection)
|
|
||||||
let refreshInProgress = false;
|
|
||||||
let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null;
|
|
||||||
|
|
||||||
// Helper function to refresh downloads (avoids `this` binding issues)
|
// Helper function to refresh downloads (avoids `this` binding issues)
|
||||||
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
|
||||||
// If a refresh is already in progress, queue this request instead
|
|
||||||
if (refreshInProgress) {
|
|
||||||
console.debug('🔄 Refresh already in progress, queuing request for user:', userId);
|
|
||||||
pendingRefreshRequest = { userId, statusFilter };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshInProgress = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Refreshing downloads for user:', userId);
|
console.log('🔄 Refreshing downloads for user:', userId);
|
||||||
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>(
|
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>(
|
||||||
@ -120,15 +105,6 @@ function createDownloadsStore() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh downloads:', error);
|
console.error('Failed to refresh downloads:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
refreshInProgress = false;
|
|
||||||
|
|
||||||
// Process queued request if any
|
|
||||||
if (pendingRefreshRequest) {
|
|
||||||
const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest;
|
|
||||||
pendingRefreshRequest = null;
|
|
||||||
await refreshDownloads(queuedUserId, queuedFilter);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Home screen data store - featured items, continue watching, recently added
|
|
||||||
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { MediaItem } from "$lib/api/types";
|
import type { MediaItem } from "$lib/api/types";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// Library state store
|
// Library state store
|
||||||
// TRACES: UR-007, UR-008, UR-029, UR-030 | DR-007, DR-011, DR-033
|
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
* The backend handles all countdown logic and decisions.
|
* The backend handles all countdown logic and decisions.
|
||||||
*
|
*
|
||||||
* The backend emits ShowNextEpisodePopup and CountdownTick events to update this store.
|
* The backend emits ShowNextEpisodePopup and CountdownTick events to update this store.
|
||||||
*
|
|
||||||
* TRACES: UR-023 | DR-026, DR-047, DR-048
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Tests for playback mode store
|
|
||||||
// TRACES: UR-010 | DR-037
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
*
|
*
|
||||||
* Most business logic moved to Rust (src-tauri/src/playback_mode/mod.rs)
|
* Most business logic moved to Rust (src-tauri/src/playback_mode/mod.rs)
|
||||||
*
|
*
|
||||||
* TRACES: UR-010 | IR-012 | DR-037
|
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
* @req: IR-012 - Jellyfin Sessions API for remote playback control
|
||||||
|
* @req: DR-037 - Remote session browser and control UI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, get, derived } from "svelte/store";
|
import { writable, get, derived } from "svelte/store";
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
* backend events via playerEvents.ts. User actions are sent as commands
|
* backend events via playerEvents.ts. User actions are sent as commands
|
||||||
* to the Rust backend, which drives state changes.
|
* to the Rust backend, which drives state changes.
|
||||||
*
|
*
|
||||||
* TRACES: UR-005 | DR-001, DR-009
|
* @req: UR-005 - Control media playback (pause, play, skip, scrub)
|
||||||
|
* @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error)
|
||||||
|
* @req: DR-009 - Audio player UI (mini player, full screen)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
@ -26,7 +28,6 @@ export interface MergedMediaItem {
|
|||||||
mediaType: "audio" | "video";
|
mediaType: "audio" | "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005 | DR-001
|
|
||||||
export type PlayerState =
|
export type PlayerState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "loading"; media: MediaItem }
|
| { kind: "loading"; media: MediaItem }
|
||||||
|
|||||||
@ -3,8 +3,6 @@
|
|||||||
// This store listens for queue_changed events from the Rust backend
|
// This store listens for queue_changed events from the Rust backend
|
||||||
// and provides reactive state for the frontend. All business logic
|
// and provides reactive state for the frontend. All business logic
|
||||||
// (shuffle order, next/previous calculations, etc.) is handled by Rust.
|
// (shuffle order, next/previous calculations, etc.) is handled by Rust.
|
||||||
//
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
|
||||||
|
|
||||||
import { writable, derived, get } from "svelte/store";
|
import { writable, derived, get } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
@ -103,42 +101,34 @@ function createQueueStore() {
|
|||||||
// All queue operations now invoke backend commands
|
// All queue operations now invoke backend commands
|
||||||
// Backend handles all business logic and emits events
|
// Backend handles all business logic and emits events
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005
|
|
||||||
async function next() {
|
async function next() {
|
||||||
await invoke("player_next");
|
await invoke("player_next");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005
|
|
||||||
async function previous() {
|
async function previous() {
|
||||||
await invoke("player_previous");
|
await invoke("player_previous");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
|
||||||
async function skipTo(index: number) {
|
async function skipTo(index: number) {
|
||||||
await invoke("player_skip_to", { index });
|
await invoke("player_skip_to", { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005
|
|
||||||
async function toggleShuffle() {
|
async function toggleShuffle() {
|
||||||
await invoke("player_toggle_shuffle");
|
await invoke("player_toggle_shuffle");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-005, UR-015 | DR-005
|
|
||||||
async function cycleRepeat() {
|
async function cycleRepeat() {
|
||||||
await invoke("player_cycle_repeat");
|
await invoke("player_cycle_repeat");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-015 | DR-020
|
|
||||||
async function removeFromQueue(index: number) {
|
async function removeFromQueue(index: number) {
|
||||||
await invoke("player_remove_from_queue", { index });
|
await invoke("player_remove_from_queue", { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-015 | DR-020
|
|
||||||
async function moveInQueue(fromIndex: number, toIndex: number) {
|
async function moveInQueue(fromIndex: number, toIndex: number) {
|
||||||
await invoke("player_move_in_queue", { fromIndex, toIndex });
|
await invoke("player_move_in_queue", { fromIndex, toIndex });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: UR-015 | DR-020
|
|
||||||
async function addToQueue(items: MediaItem | MediaItem[], position: "next" | "end" = "end") {
|
async function addToQueue(items: MediaItem | MediaItem[], position: "next" | "end" = "end") {
|
||||||
const toAdd = Array.isArray(items) ? items : [items];
|
const toAdd = Array.isArray(items) ? items : [items];
|
||||||
const trackIds = toAdd.map((item) => item.id);
|
const trackIds = toAdd.map((item) => item.id);
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
// Tests for sessions store
|
|
||||||
// TRACES: UR-010 | DR-037
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { Session } from "$lib/api/types";
|
import type { Session } from "$lib/api/types";
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
// Remote sessions store for controlling playback on other Jellyfin clients
|
// Remote sessions store for controlling playback on other Jellyfin clients
|
||||||
// TRACES: UR-010 | DR-037
|
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
* All logic is in the Rust backend (PlayerController).
|
* All logic is in the Rust backend (PlayerController).
|
||||||
*
|
*
|
||||||
* The backend emits SleepTimerChanged events to update this store.
|
* The backend emits SleepTimerChanged events to update this store.
|
||||||
*
|
|
||||||
* TRACES: UR-026 | DR-029
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, derived } from "svelte/store";
|
import { writable, derived } from "svelte/store";
|
||||||
|
|||||||
@ -1,297 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function to create debounced functions
|
|
||||||
* Used in GenericMediaListPage for search input debouncing
|
|
||||||
*/
|
|
||||||
export function createDebouncedFunction<T extends (...args: any[]) => any>(
|
|
||||||
fn: T,
|
|
||||||
delayMs: number = 300
|
|
||||||
) {
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
return (...args: Parameters<T>) => {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
fn(...args);
|
|
||||||
timeout = null;
|
|
||||||
}, delayMs);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Debounce Utility", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Basic Debouncing", () => {
|
|
||||||
it("should delay function execution", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
debouncedFn("test");
|
|
||||||
|
|
||||||
// Should not be called immediately
|
|
||||||
expect(mockFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Advance time by 300ms
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Now it should be called
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("test");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not call function if timer is cleared before delay", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
debouncedFn("test");
|
|
||||||
vi.advanceTimersByTime(150);
|
|
||||||
|
|
||||||
// Call again before delay completes
|
|
||||||
debouncedFn("updated");
|
|
||||||
|
|
||||||
// First timeout should be cleared
|
|
||||||
vi.advanceTimersByTime(150);
|
|
||||||
|
|
||||||
// Should still not have been called
|
|
||||||
expect(mockFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Complete the second timeout
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should be called once with latest value
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("updated");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiple rapid calls", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
// Rapid calls
|
|
||||||
debouncedFn("a");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
debouncedFn("b");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
debouncedFn("c");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// Should not be called yet
|
|
||||||
expect(mockFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Complete the final timeout
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should be called once with the last value
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("c");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should call multiple times if calls are spaced out", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
debouncedFn("first");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should be called
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("first");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Wait enough time and call again
|
|
||||||
vi.advanceTimersByTime(200);
|
|
||||||
debouncedFn("second");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should be called again
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("second");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Custom Delay", () => {
|
|
||||||
it("should respect custom delay values", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 500);
|
|
||||||
|
|
||||||
debouncedFn("test");
|
|
||||||
|
|
||||||
// 300ms shouldn't trigger
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
expect(mockFn).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// But 500ms should
|
|
||||||
vi.advanceTimersByTime(200);
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle zero delay", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 0);
|
|
||||||
|
|
||||||
debouncedFn("test");
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(0);
|
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("test");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Search Use Case", () => {
|
|
||||||
it("should debounce search queries correctly", () => {
|
|
||||||
const mockSearch = vi.fn();
|
|
||||||
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
|
|
||||||
|
|
||||||
// User types "t"
|
|
||||||
debouncedSearch("t");
|
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// User types "te" quickly
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
debouncedSearch("te");
|
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// User types "tes"
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
debouncedSearch("tes");
|
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// User types "test"
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
debouncedSearch("test");
|
|
||||||
expect(mockSearch).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Wait for debounce delay
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should only call once with final value
|
|
||||||
expect(mockSearch).toHaveBeenCalledWith("test");
|
|
||||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should cancel pending search if input clears quickly", () => {
|
|
||||||
const mockSearch = vi.fn();
|
|
||||||
const debouncedSearch = createDebouncedFunction(mockSearch, 300);
|
|
||||||
|
|
||||||
// User types "test"
|
|
||||||
debouncedSearch("test");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// User clears input
|
|
||||||
debouncedSearch("");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// User types again
|
|
||||||
debouncedSearch("new");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should only call with final value
|
|
||||||
expect(mockSearch).toHaveBeenCalledWith("new");
|
|
||||||
expect(mockSearch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work with async search functions", () => {
|
|
||||||
const mockAsyncSearch = vi.fn().mockResolvedValue([]);
|
|
||||||
const debouncedSearch = createDebouncedFunction(mockAsyncSearch, 300);
|
|
||||||
|
|
||||||
debouncedSearch("query");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
expect(mockAsyncSearch).toHaveBeenCalledWith("query");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Generic Parameter Handling", () => {
|
|
||||||
it("should preserve function parameters", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
const obj = { id: "123", name: "test" };
|
|
||||||
debouncedFn("string", 42, obj);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("string", 42, obj);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle functions with no parameters", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
debouncedFn();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalledWith();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex object parameters", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
query: "test",
|
|
||||||
filters: { type: "Audio", limit: 100 },
|
|
||||||
sort: { by: "SortName", order: "Ascending" },
|
|
||||||
};
|
|
||||||
|
|
||||||
debouncedFn(options);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalledWith(options);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Memory Management", () => {
|
|
||||||
it("should clean up timeout after execution", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 300);
|
|
||||||
|
|
||||||
debouncedFn("test");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const callCount = mockFn.mock.calls.length;
|
|
||||||
|
|
||||||
// Call again shortly after
|
|
||||||
debouncedFn("test2");
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
// Additional calls within delay shouldn't cause multiple executions
|
|
||||||
debouncedFn("test3");
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
// Should only have been called 2 times total
|
|
||||||
expect(mockFn.mock.calls.length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle repeated debouncing without memory leaks", () => {
|
|
||||||
const mockFn = vi.fn();
|
|
||||||
const debouncedFn = createDebouncedFunction(mockFn, 50);
|
|
||||||
|
|
||||||
// Simulate 100 rapid calls
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
debouncedFn(`call${i}`);
|
|
||||||
vi.advanceTimersByTime(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete final timeout
|
|
||||||
vi.advanceTimersByTime(50);
|
|
||||||
|
|
||||||
// Should only be called once with the last value
|
|
||||||
expect(mockFn).toHaveBeenCalledWith("call99");
|
|
||||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* Duration formatting utility tests
|
|
||||||
*
|
|
||||||
* TRACES: UR-005 | DR-028
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { formatDuration, formatSecondsDuration } from "./duration";
|
|
||||||
|
|
||||||
describe("formatDuration", () => {
|
|
||||||
it("should format duration from Jellyfin ticks (mm:ss format)", () => {
|
|
||||||
// 1 second = 10,000,000 ticks
|
|
||||||
expect(formatDuration(10000000)).toBe("0:01");
|
|
||||||
expect(formatDuration(60000000)).toBe("1:00");
|
|
||||||
expect(formatDuration(600000000)).toBe("10:00");
|
|
||||||
expect(formatDuration(3661000000)).toBe("61:01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format duration with hh:mm:ss format", () => {
|
|
||||||
// 1 hour = 3600 seconds
|
|
||||||
expect(formatDuration(36000000000, "hh:mm:ss")).toBe("1:00:00");
|
|
||||||
expect(formatDuration(36600000000, "hh:mm:ss")).toBe("1:01:40");
|
|
||||||
expect(formatDuration(3661000000, "hh:mm:ss")).toBe("0:01:01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return empty string for undefined or 0 ticks", () => {
|
|
||||||
expect(formatDuration(undefined)).toBe("");
|
|
||||||
expect(formatDuration(0)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pad seconds with leading zero", () => {
|
|
||||||
expect(formatDuration(5000000)).toBe("0:05");
|
|
||||||
expect(formatDuration(15000000)).toBe("0:15");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle large durations", () => {
|
|
||||||
// 2 hours 30 minutes 45 seconds
|
|
||||||
expect(formatDuration(90450000000, "hh:mm:ss")).toBe("2:30:45");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatSecondsDuration", () => {
|
|
||||||
it("should format duration from seconds (mm:ss format)", () => {
|
|
||||||
expect(formatSecondsDuration(1)).toBe("0:01");
|
|
||||||
expect(formatSecondsDuration(60)).toBe("1:00");
|
|
||||||
expect(formatSecondsDuration(61)).toBe("1:01");
|
|
||||||
expect(formatSecondsDuration(3661)).toBe("61:01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should format duration with hh:mm:ss format", () => {
|
|
||||||
expect(formatSecondsDuration(3600, "hh:mm:ss")).toBe("1:00:00");
|
|
||||||
expect(formatSecondsDuration(3661, "hh:mm:ss")).toBe("1:01:01");
|
|
||||||
expect(formatSecondsDuration(7325, "hh:mm:ss")).toBe("2:02:05");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should pad minutes and seconds with leading zeros", () => {
|
|
||||||
expect(formatSecondsDuration(5, "hh:mm:ss")).toBe("0:00:05");
|
|
||||||
expect(formatSecondsDuration(65, "hh:mm:ss")).toBe("0:01:05");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Duration formatting utilities
|
|
||||||
*
|
|
||||||
* Jellyfin uses "ticks" for duration where 10,000,000 ticks = 1 second
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Jellyfin ticks to formatted duration string
|
|
||||||
* @param ticks Duration in Jellyfin ticks (10M ticks = 1 second)
|
|
||||||
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
|
||||||
* @returns Formatted duration string or empty string if no ticks
|
|
||||||
*/
|
|
||||||
export function formatDuration(ticks?: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
|
||||||
if (!ticks) return "";
|
|
||||||
|
|
||||||
// Jellyfin uses 10,000,000 ticks per second
|
|
||||||
const TICKS_PER_SECOND = 10000000;
|
|
||||||
const totalSeconds = Math.floor(ticks / TICKS_PER_SECOND);
|
|
||||||
|
|
||||||
if (format === "hh:mm:ss") {
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default "mm:ss" format
|
|
||||||
const minutes = Math.floor(totalSeconds / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert seconds to formatted duration string
|
|
||||||
* @param seconds Duration in seconds
|
|
||||||
* @param format Format type: "mm:ss" (default) or "hh:mm:ss"
|
|
||||||
* @returns Formatted duration string
|
|
||||||
*/
|
|
||||||
export function formatSecondsDuration(seconds: number, format: "mm:ss" | "hh:mm:ss" = "mm:ss"): string {
|
|
||||||
if (format === "hh:mm:ss") {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default "mm:ss" format
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jellyfin Field Mapping Tests
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
SORT_FIELD_MAP,
|
|
||||||
getJellyfinSortField,
|
|
||||||
normalizeSortOrder,
|
|
||||||
ITEM_TYPES,
|
|
||||||
ITEM_TYPE_GROUPS,
|
|
||||||
} from "./jellyfinFieldMapping";
|
|
||||||
|
|
||||||
describe("Jellyfin Field Mapping", () => {
|
|
||||||
describe("SORT_FIELD_MAP", () => {
|
|
||||||
it("should map frontend sort keys to Jellyfin fields", () => {
|
|
||||||
expect(SORT_FIELD_MAP.title).toBe("SortName");
|
|
||||||
expect(SORT_FIELD_MAP.artist).toBe("Artist");
|
|
||||||
expect(SORT_FIELD_MAP.album).toBe("Album");
|
|
||||||
expect(SORT_FIELD_MAP.year).toBe("ProductionYear");
|
|
||||||
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
|
|
||||||
expect(SORT_FIELD_MAP.added).toBe("DateCreated");
|
|
||||||
expect(SORT_FIELD_MAP.rating).toBe("CommunityRating");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have all common audio sorts", () => {
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("title");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("artist");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("album");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("year");
|
|
||||||
expect(SORT_FIELD_MAP).toHaveProperty("recent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have fallback sort names", () => {
|
|
||||||
expect(SORT_FIELD_MAP.name).toBe("SortName");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should map aliases to same fields", () => {
|
|
||||||
expect(SORT_FIELD_MAP.title).toBe(SORT_FIELD_MAP.name);
|
|
||||||
expect(SORT_FIELD_MAP.recent).toBe("DatePlayed");
|
|
||||||
expect(SORT_FIELD_MAP.dateAdded).toBe("DateCreated");
|
|
||||||
expect(SORT_FIELD_MAP.datePlayed).toBe("DatePlayed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getJellyfinSortField()", () => {
|
|
||||||
it("should return mapped field for known keys", () => {
|
|
||||||
expect(getJellyfinSortField("artist")).toBe("Artist");
|
|
||||||
expect(getJellyfinSortField("album")).toBe("Album");
|
|
||||||
expect(getJellyfinSortField("year")).toBe("ProductionYear");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should fallback to SortName for unknown keys", () => {
|
|
||||||
expect(getJellyfinSortField("unknown")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("invalidKey")).toBe("SortName");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be case-sensitive", () => {
|
|
||||||
// Should work with exact case
|
|
||||||
expect(getJellyfinSortField("title")).toBe("SortName");
|
|
||||||
// Unknown case variations fallback to default
|
|
||||||
expect(getJellyfinSortField("Title")).toBe("SortName");
|
|
||||||
expect(getJellyfinSortField("TITLE")).toBe("SortName");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeSortOrder()", () => {
|
|
||||||
it("should accept valid ascending orders", () => {
|
|
||||||
expect(normalizeSortOrder("Ascending")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("ascending")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("asc")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder(undefined)).toBe("Ascending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid descending orders", () => {
|
|
||||||
expect(normalizeSortOrder("Descending")).toBe("Descending");
|
|
||||||
expect(normalizeSortOrder("descending")).toBe("Descending");
|
|
||||||
expect(normalizeSortOrder("desc")).toBe("Descending");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should default to Ascending for unknown values", () => {
|
|
||||||
expect(normalizeSortOrder("invalid")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("random")).toBe("Ascending");
|
|
||||||
expect(normalizeSortOrder("")).toBe("Ascending");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ITEM_TYPES", () => {
|
|
||||||
it("should define audio types", () => {
|
|
||||||
expect(ITEM_TYPES.AUDIO).toBe("Audio");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should define video types", () => {
|
|
||||||
expect(ITEM_TYPES.MOVIE).toBe("Movie");
|
|
||||||
expect(ITEM_TYPES.SERIES).toBe("Series");
|
|
||||||
expect(ITEM_TYPES.EPISODE).toBe("Episode");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have consistent case", () => {
|
|
||||||
// Jellyfin API uses CamelCase
|
|
||||||
expect(ITEM_TYPES.MUSIC_ALBUM).toBe("MusicAlbum");
|
|
||||||
expect(ITEM_TYPES.MUSIC_ARTIST).toBe("MusicArtist");
|
|
||||||
expect(ITEM_TYPES.MUSIC_VIDEO).toBe("MusicVideo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ITEM_TYPE_GROUPS", () => {
|
|
||||||
it("should group audio types correctly", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.AUDIO);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ALBUM);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio).toContain(ITEM_TYPES.MUSIC_ARTIST);
|
|
||||||
expect(ITEM_TYPE_GROUPS.audio.length).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should group video types correctly", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.MOVIE);
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.SERIES);
|
|
||||||
expect(ITEM_TYPE_GROUPS.video).toContain(ITEM_TYPES.EPISODE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide movie and TV show subgroups", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.movies).toEqual([ITEM_TYPES.MOVIE]);
|
|
||||||
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.SERIES);
|
|
||||||
expect(ITEM_TYPE_GROUPS.tvshows).toContain(ITEM_TYPES.EPISODE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should have music alias for audio", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.music).toEqual(ITEM_TYPE_GROUPS.audio);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should provide episodes filter", () => {
|
|
||||||
expect(ITEM_TYPE_GROUPS.episodes).toEqual([ITEM_TYPES.EPISODE]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Jellyfin Field Mapping
|
|
||||||
*
|
|
||||||
* Maps frontend sort option keys to Jellyfin API field names.
|
|
||||||
* This provides the single source of truth for how different UI sort options
|
|
||||||
* translate to backend database queries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps friendly sort names to Jellyfin API field names
|
|
||||||
* Used by all library views for consistent sorting
|
|
||||||
*/
|
|
||||||
export const SORT_FIELD_MAP = {
|
|
||||||
// Default/fallback sorts
|
|
||||||
title: "SortName",
|
|
||||||
name: "SortName",
|
|
||||||
|
|
||||||
// Audio-specific sorts
|
|
||||||
artist: "Artist",
|
|
||||||
album: "Album",
|
|
||||||
year: "ProductionYear",
|
|
||||||
recent: "DatePlayed",
|
|
||||||
added: "DateCreated",
|
|
||||||
rating: "CommunityRating",
|
|
||||||
duration: "RunTimeTicks",
|
|
||||||
|
|
||||||
// Video-specific sorts
|
|
||||||
dateAdded: "DateCreated",
|
|
||||||
datePlayed: "DatePlayed",
|
|
||||||
IMDBRating: "CommunityRating",
|
|
||||||
|
|
||||||
// Video series sorts
|
|
||||||
premiered: "PremiereDate",
|
|
||||||
episodeCount: "ChildCount",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type-safe sort field names
|
|
||||||
*/
|
|
||||||
export type SortField = keyof typeof SORT_FIELD_MAP;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get Jellyfin API field name for a frontend sort key
|
|
||||||
* @param key Frontend sort key (e.g., "artist")
|
|
||||||
* @returns Jellyfin field name (e.g., "Artist")
|
|
||||||
*/
|
|
||||||
export function getJellyfinSortField(key: string): string {
|
|
||||||
const field = SORT_FIELD_MAP[key as SortField];
|
|
||||||
return field || "SortName"; // Fallback to title sort
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate sort order string
|
|
||||||
* @param order Sort order value
|
|
||||||
* @returns Valid sort order for Jellyfin API
|
|
||||||
*/
|
|
||||||
export function normalizeSortOrder(order: string | undefined): "Ascending" | "Descending" {
|
|
||||||
if (order === "Descending" || order === "desc" || order === "descending") {
|
|
||||||
return "Descending";
|
|
||||||
}
|
|
||||||
return "Ascending";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Jellyfin ItemType constants for filtering
|
|
||||||
* Used in getItems() and search() calls
|
|
||||||
*/
|
|
||||||
export const ITEM_TYPES = {
|
|
||||||
// Audio types
|
|
||||||
AUDIO: "Audio",
|
|
||||||
MUSIC_ALBUM: "MusicAlbum",
|
|
||||||
MUSIC_ARTIST: "MusicArtist",
|
|
||||||
MUSIC_VIDEO: "MusicVideo",
|
|
||||||
|
|
||||||
// Video types
|
|
||||||
MOVIE: "Movie",
|
|
||||||
SERIES: "Series",
|
|
||||||
SEASON: "Season",
|
|
||||||
EPISODE: "Episode",
|
|
||||||
|
|
||||||
// Playlist
|
|
||||||
PLAYLIST: "Playlist",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Predefined item type groups for easy filtering
|
|
||||||
*/
|
|
||||||
export const ITEM_TYPE_GROUPS = {
|
|
||||||
audio: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
|
|
||||||
music: [ITEM_TYPES.AUDIO, ITEM_TYPES.MUSIC_ALBUM, ITEM_TYPES.MUSIC_ARTIST],
|
|
||||||
video: [ITEM_TYPES.MOVIE, ITEM_TYPES.SERIES, ITEM_TYPES.EPISODE],
|
|
||||||
movies: [ITEM_TYPES.MOVIE],
|
|
||||||
tvshows: [ITEM_TYPES.SERIES, ITEM_TYPES.SEASON, ITEM_TYPES.EPISODE],
|
|
||||||
episodes: [ITEM_TYPES.EPISODE],
|
|
||||||
} as const;
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Input validation utility tests
|
|
||||||
*
|
|
||||||
* TRACES: UR-009, UR-025 | DR-015
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import {
|
|
||||||
validateItemId,
|
|
||||||
validateImageType,
|
|
||||||
validateMediaSourceId,
|
|
||||||
validateNumericParam,
|
|
||||||
validateQueryParamValue,
|
|
||||||
} from "./validation";
|
|
||||||
|
|
||||||
describe("validateItemId", () => {
|
|
||||||
it("should accept valid item IDs", () => {
|
|
||||||
expect(() => validateItemId("123abc")).not.toThrow();
|
|
||||||
expect(() => validateItemId("abc-123_def")).not.toThrow();
|
|
||||||
expect(() => validateItemId("12345")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject empty or non-string IDs", () => {
|
|
||||||
expect(() => validateItemId("")).toThrow("must be a non-empty string");
|
|
||||||
expect(() => validateItemId(null as any)).toThrow("must be a non-empty string");
|
|
||||||
expect(() => validateItemId(undefined as any)).toThrow("must be a non-empty string");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject IDs exceeding max length", () => {
|
|
||||||
expect(() => validateItemId("a".repeat(51))).toThrow("exceeds maximum length");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject IDs with invalid characters", () => {
|
|
||||||
expect(() => validateItemId("abc/def")).toThrow("contains invalid characters");
|
|
||||||
expect(() => validateItemId("abc..def")).toThrow("contains invalid characters");
|
|
||||||
expect(() => validateItemId("abc def")).toThrow("contains invalid characters");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateImageType", () => {
|
|
||||||
it("should accept valid image types", () => {
|
|
||||||
expect(() => validateImageType("Primary")).not.toThrow();
|
|
||||||
expect(() => validateImageType("Backdrop")).not.toThrow();
|
|
||||||
expect(() => validateImageType("Banner")).not.toThrow();
|
|
||||||
expect(() => validateImageType("Logo")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject invalid image types", () => {
|
|
||||||
expect(() => validateImageType("InvalidType")).toThrow("not a valid image type");
|
|
||||||
expect(() => validateImageType("..")).toThrow("not a valid image type");
|
|
||||||
expect(() => validateImageType("Primary/Avatar")).toThrow("not a valid image type");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject empty or non-string types", () => {
|
|
||||||
expect(() => validateImageType("")).toThrow("must be a non-empty string");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateMediaSourceId", () => {
|
|
||||||
it("should accept valid media source IDs", () => {
|
|
||||||
expect(() => validateMediaSourceId("source-123")).not.toThrow();
|
|
||||||
expect(() => validateMediaSourceId("video_stream_1")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject IDs with invalid characters", () => {
|
|
||||||
expect(() => validateMediaSourceId("source/path")).toThrow("contains invalid characters");
|
|
||||||
expect(() => validateMediaSourceId("source..path")).toThrow("contains invalid characters");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject IDs exceeding max length", () => {
|
|
||||||
expect(() => validateMediaSourceId("a".repeat(51))).toThrow("exceeds maximum length");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateNumericParam", () => {
|
|
||||||
it("should accept valid numbers", () => {
|
|
||||||
expect(validateNumericParam(100)).toBe(100);
|
|
||||||
expect(validateNumericParam(0)).toBe(0);
|
|
||||||
expect(validateNumericParam(9999)).toBe(9999);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject non-integers", () => {
|
|
||||||
expect(() => validateNumericParam(10.5)).toThrow("must be an integer");
|
|
||||||
expect(() => validateNumericParam("100")).toThrow("must be an integer");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should respect min and max bounds", () => {
|
|
||||||
expect(() => validateNumericParam(-1, 0, 100)).toThrow("must be between 0 and 100");
|
|
||||||
expect(() => validateNumericParam(101, 0, 100)).toThrow("must be between 0 and 100");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow custom bounds", () => {
|
|
||||||
expect(validateNumericParam(50, 10, 100)).toBe(50);
|
|
||||||
expect(() => validateNumericParam(5, 10, 100)).toThrow("must be between 10 and 100");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateQueryParamValue", () => {
|
|
||||||
it("should accept valid query param values", () => {
|
|
||||||
expect(() => validateQueryParamValue("abc123")).not.toThrow();
|
|
||||||
expect(() => validateQueryParamValue("value-with-dash")).not.toThrow();
|
|
||||||
expect(() => validateQueryParamValue("value_with_underscore")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject values with invalid characters", () => {
|
|
||||||
expect(() => validateQueryParamValue("value with spaces")).toThrow("contains invalid characters");
|
|
||||||
expect(() => validateQueryParamValue("value/path")).toThrow("contains invalid characters");
|
|
||||||
expect(() => validateQueryParamValue("value?query")).toThrow("contains invalid characters");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject values exceeding max length", () => {
|
|
||||||
expect(() => validateQueryParamValue("a".repeat(101))).toThrow("exceeds maximum length");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should respect custom max length", () => {
|
|
||||||
expect(() => validateQueryParamValue("a".repeat(50), 40)).toThrow("exceeds maximum length");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Input validation utilities for security and data integrity
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate Jellyfin item ID format
|
|
||||||
* Item IDs should be non-empty alphanumeric strings with optional dashes/underscores
|
|
||||||
*/
|
|
||||||
export function validateItemId(itemId: string): void {
|
|
||||||
if (!itemId || typeof itemId !== "string") {
|
|
||||||
throw new Error("Invalid itemId: must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemId.length > 50) {
|
|
||||||
throw new Error("Invalid itemId: exceeds maximum length of 50 characters");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jellyfin item IDs are typically UUIDs or numeric IDs
|
|
||||||
if (!/^[a-zA-Z0-9\-_]+$/.test(itemId)) {
|
|
||||||
throw new Error("Invalid itemId: contains invalid characters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate image type to prevent path traversal attacks
|
|
||||||
*/
|
|
||||||
export function validateImageType(imageType: string): void {
|
|
||||||
if (!imageType || typeof imageType !== "string") {
|
|
||||||
throw new Error("Invalid imageType: must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow known image types
|
|
||||||
const validImageTypes = [
|
|
||||||
"Primary",
|
|
||||||
"Backdrop",
|
|
||||||
"Banner",
|
|
||||||
"Disc",
|
|
||||||
"Box",
|
|
||||||
"Logo",
|
|
||||||
"Thumb",
|
|
||||||
"Art",
|
|
||||||
"Chapter",
|
|
||||||
"Keyframe",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!validImageTypes.includes(imageType)) {
|
|
||||||
throw new Error(`Invalid imageType: "${imageType}" is not a valid image type`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate media source ID format
|
|
||||||
*/
|
|
||||||
export function validateMediaSourceId(mediaSourceId: string): void {
|
|
||||||
if (!mediaSourceId || typeof mediaSourceId !== "string") {
|
|
||||||
throw new Error("Invalid mediaSourceId: must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaSourceId.length > 50) {
|
|
||||||
throw new Error("Invalid mediaSourceId: exceeds maximum length");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9\-_]+$/.test(mediaSourceId)) {
|
|
||||||
throw new Error("Invalid mediaSourceId: contains invalid characters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate URL path segment to prevent directory traversal
|
|
||||||
* Disallows: "..", ".", and characters that could enable attacks
|
|
||||||
*/
|
|
||||||
export function validateUrlPathSegment(segment: string): void {
|
|
||||||
if (!segment || typeof segment !== "string") {
|
|
||||||
throw new Error("Invalid path segment: must be a non-empty string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segment === ".." || segment === ".") {
|
|
||||||
throw new Error("Invalid path segment: directory traversal not allowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject path separators and null bytes
|
|
||||||
if (/[\/\\%]/.test(segment)) {
|
|
||||||
throw new Error("Invalid path segment: contains invalid characters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate numeric parameter (width, height, quality, etc.)
|
|
||||||
*/
|
|
||||||
export function validateNumericParam(value: unknown, min = 0, max = 10000, name = "parameter"): number {
|
|
||||||
const num = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isInteger(num)) {
|
|
||||||
throw new Error(`Invalid ${name}: must be an integer`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (num < min || num > max) {
|
|
||||||
throw new Error(`Invalid ${name}: must be between ${min} and ${max}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return num;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize query parameter value - allows alphanumeric, dash, underscore
|
|
||||||
*/
|
|
||||||
export function validateQueryParamValue(value: string, maxLength = 100): void {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
throw new Error("Query parameter value must be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.length > maxLength) {
|
|
||||||
throw new Error(`Query parameter exceeds maximum length of ${maxLength}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow only safe characters in query params
|
|
||||||
if (!/^[a-zA-Z0-9\-_.~]+$/.test(value)) {
|
|
||||||
throw new Error("Query parameter contains invalid characters");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,19 +18,28 @@
|
|||||||
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
import MiniPlayer from "$lib/components/player/MiniPlayer.svelte";
|
||||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||||
import BottomNav from "$lib/components/BottomNav.svelte";
|
import BottomNav from "$lib/components/BottomNav.svelte";
|
||||||
import { isInitialized, pendingSyncCount, isAndroid, shuffle, repeat, hasNext, hasPrevious, showSleepTimerModal } from "$lib/stores/appState";
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
let isInitialized = $state(false);
|
||||||
|
let pendingSyncCount = $state(0);
|
||||||
|
let isAndroid = $state(false);
|
||||||
|
let shuffle = $state(false);
|
||||||
|
let repeat = $state<"off" | "all" | "one">("off");
|
||||||
|
let hasNext = $state(false);
|
||||||
|
let hasPrevious = $state(false);
|
||||||
|
let showSleepTimerModal = $state(false);
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Initialize auth state (restore session from secure storage)
|
// Initialize auth state (restore session from secure storage)
|
||||||
await auth.initialize();
|
await auth.initialize();
|
||||||
isInitialized.set(true);
|
isInitialized = true;
|
||||||
|
|
||||||
// Detect platform (Android needs global mini player)
|
// Detect platform (Android needs global mini player)
|
||||||
try {
|
try {
|
||||||
const platformName = await platform();
|
const platformName = await platform();
|
||||||
isAndroid.set(platformName === "android");
|
isAndroid = platformName === "android";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Platform detection failed:", err);
|
console.error("Platform detection failed:", err);
|
||||||
}
|
}
|
||||||
@ -47,6 +56,10 @@
|
|||||||
// Initialize playback mode and session monitoring
|
// Initialize playback mode and session monitoring
|
||||||
playbackMode.initializeSessionMonitoring();
|
playbackMode.initializeSessionMonitoring();
|
||||||
await playbackMode.refresh();
|
await playbackMode.refresh();
|
||||||
|
|
||||||
|
// Poll for queue status (needed for mini player controls on all platforms)
|
||||||
|
updateQueueStatus(); // Initial update
|
||||||
|
pollInterval = setInterval(updateQueueStatus, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@ -54,31 +67,47 @@
|
|||||||
cleanupDownloadEvents();
|
cleanupDownloadEvents();
|
||||||
connectivity.stopMonitoring();
|
connectivity.stopMonitoring();
|
||||||
syncService.stop();
|
syncService.stop();
|
||||||
auth.cleanupEventListeners();
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function updateQueueStatus() {
|
||||||
|
try {
|
||||||
|
const queue = await invoke<{
|
||||||
|
items: any[];
|
||||||
|
currentIndex: number | null;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrevious: boolean;
|
||||||
|
shuffle: boolean;
|
||||||
|
repeat: string;
|
||||||
|
}>("player_get_queue");
|
||||||
|
|
||||||
|
hasNext = queue.hasNext;
|
||||||
|
hasPrevious = queue.hasPrevious;
|
||||||
|
shuffle = queue.shuffle;
|
||||||
|
repeat = queue.repeat as "off" | "all" | "one";
|
||||||
|
} catch (e) {
|
||||||
|
// Silently ignore polling errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Connectivity monitoring is now started early in auth.initialize()
|
// Connectivity monitoring is now started early in auth.initialize()
|
||||||
// This effect is kept only for when the user logs in during the session
|
// This effect is kept only for when the user logs in during the session
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($isAuthenticated) {
|
if ($isAuthenticated) {
|
||||||
// Check if monitoring is already running by attempting to get status
|
// Check if monitoring is already running by attempting to get status
|
||||||
// If not running, start it (handles login during current session)
|
// If not running, start it (handles login during current session)
|
||||||
auth.getCurrentSession().then((session) => {
|
const session = auth.getCurrentSession();
|
||||||
if (session?.serverUrl) {
|
if (session?.serverUrl) {
|
||||||
connectivity.forceCheck().catch((error) => {
|
connectivity.forceCheck().catch(() => {
|
||||||
// If check fails, monitoring might not be started yet, so start it
|
// If check fails, monitoring might not be started yet, so start it
|
||||||
console.debug("[Layout] Queue status check failed, starting monitoring:", error);
|
|
||||||
connectivity.startMonitoring(session.serverUrl, {
|
connectivity.startMonitoring(session.serverUrl, {
|
||||||
onServerReconnected: () => {
|
onServerReconnected: () => {
|
||||||
// Retry session verification when server becomes reachable
|
// Retry session verification when server becomes reachable
|
||||||
auth.retryVerification();
|
auth.retryVerification();
|
||||||
},
|
},
|
||||||
}).catch((monitorError) => {
|
|
||||||
console.error("[Layout] Failed to start connectivity monitoring:", monitorError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -86,8 +115,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($isAuthenticated) {
|
if ($isAuthenticated) {
|
||||||
const updateCount = async () => {
|
const updateCount = async () => {
|
||||||
const count = await syncService.getPendingCount();
|
pendingSyncCount = await syncService.getPendingCount();
|
||||||
pendingSyncCount.set(count);
|
|
||||||
};
|
};
|
||||||
updateCount();
|
updateCount();
|
||||||
// Update every 10 seconds
|
// Update every 10 seconds
|
||||||
@ -106,9 +134,9 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>You're offline. Some features may be limited.</span>
|
<span>You're offline. Some features may be limited.</span>
|
||||||
{#if $pendingSyncCount > 0}
|
{#if pendingSyncCount > 0}
|
||||||
<span class="bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
<span class="bg-white/20 px-2 py-0.5 rounded-full text-xs">
|
||||||
{$pendingSyncCount} pending sync{$pendingSyncCount !== 1 ? 's' : ''}
|
{pendingSyncCount} pending sync{pendingSyncCount !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@ -134,29 +162,29 @@
|
|||||||
<!-- Android: Show on all routes (except player/login) -->
|
<!-- Android: Show on all routes (except player/login) -->
|
||||||
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
|
<!-- Desktop: Show on non-library routes (library layout has its own MiniPlayer) -->
|
||||||
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
|
{#if !$page.url.pathname.startsWith('/player/') && !$page.url.pathname.startsWith('/login')}
|
||||||
{#if $isAndroid || !$page.url.pathname.startsWith('/library')}
|
{#if isAndroid || !$page.url.pathname.startsWith('/library')}
|
||||||
<MiniPlayer
|
<MiniPlayer
|
||||||
media={$currentMedia}
|
media={$currentMedia}
|
||||||
isPlaying={$isPlaying}
|
isPlaying={$isPlaying}
|
||||||
position={$playbackPosition}
|
position={$playbackPosition}
|
||||||
duration={$playbackDuration}
|
duration={$playbackDuration}
|
||||||
shuffle={$shuffle}
|
{shuffle}
|
||||||
repeat={$repeat}
|
{repeat}
|
||||||
hasNext={$hasNext}
|
{hasNext}
|
||||||
hasPrevious={$hasPrevious}
|
{hasPrevious}
|
||||||
onExpand={() => {
|
onExpand={() => {
|
||||||
// Navigate to player page when mini player is expanded
|
// Navigate to player page when mini player is expanded
|
||||||
if ($currentMedia) {
|
if ($currentMedia) {
|
||||||
goto(`/player/${$currentMedia.id}`);
|
goto(`/player/${$currentMedia.id}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSleepTimerClick={() => showSleepTimerModal.set(true)}
|
onSleepTimerClick={() => showSleepTimerModal = true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sleep Timer Modal -->
|
<!-- Sleep Timer Modal -->
|
||||||
<SleepTimerModal
|
<SleepTimerModal
|
||||||
isOpen={$showSleepTimerModal}
|
isOpen={showSleepTimerModal}
|
||||||
onClose={() => showSleepTimerModal.set(false)}
|
onClose={() => showSleepTimerModal = false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -13,11 +13,6 @@
|
|||||||
|
|
||||||
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
|
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
|
||||||
|
|
||||||
// Filter out Playlist libraries - they belong in Music sub-library
|
|
||||||
const visibleLibraries = $derived.by(() => {
|
|
||||||
return $libraries.filter(lib => lib.collectionType !== "playlists");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track if we've done an initial load and previous server state
|
// Track if we've done an initial load and previous server state
|
||||||
let hasLoadedOnce = false;
|
let hasLoadedOnce = false;
|
||||||
let previousServerReachable = false;
|
let previousServerReachable = false;
|
||||||
@ -180,13 +175,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if visibleLibraries.length === 0}
|
{:else if $libraries.length === 0}
|
||||||
<div class="text-center py-12 text-gray-400">
|
<div class="text-center py-12 text-gray-400">
|
||||||
<p>No libraries found</p>
|
<p>No libraries found</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
{#each visibleLibraries as lib (lib.id)}
|
{#each $libraries as lib (lib.id)}
|
||||||
<MediaCard
|
<MediaCard
|
||||||
item={lib}
|
item={lib}
|
||||||
size="medium"
|
size="medium"
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { auth } from "$lib/stores/auth";
|
|
||||||
import { currentLibrary } from "$lib/stores/library";
|
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
@ -10,10 +7,9 @@
|
|||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
route: string;
|
route: string;
|
||||||
backgroundImage?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let categories: Category[] = [
|
const categories: Category[] = [
|
||||||
{
|
{
|
||||||
id: "tracks",
|
id: "tracks",
|
||||||
name: "Tracks",
|
name: "Tracks",
|
||||||
@ -35,6 +31,13 @@
|
|||||||
description: "Browse by album",
|
description: "Browse by album",
|
||||||
route: "/library/music/albums",
|
route: "/library/music/albums",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "playlists",
|
||||||
|
name: "Playlists",
|
||||||
|
icon: "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01",
|
||||||
|
description: "Your playlists",
|
||||||
|
route: "/library/music/playlists",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "genres",
|
id: "genres",
|
||||||
name: "Genres",
|
name: "Genres",
|
||||||
@ -44,55 +47,6 @@
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fetch album art for categories
|
|
||||||
async function loadCategoryImages() {
|
|
||||||
if (!$currentLibrary) {
|
|
||||||
console.log("Current library not set yet, retrying...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const repo = auth.getRepository();
|
|
||||||
|
|
||||||
// Fetch a recent album to use as background for albums category
|
|
||||||
const albums = await repo.getLatestItems($currentLibrary.id, 5);
|
|
||||||
if (albums.length > 0) {
|
|
||||||
const albumWithImage = albums.find(a => a.primaryImageTag);
|
|
||||||
if (albumWithImage) {
|
|
||||||
categories = categories.map(cat =>
|
|
||||||
cat.id === "albums"
|
|
||||||
? { ...cat, backgroundImage: albumWithImage.id }
|
|
||||||
: cat
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch a recent audio track for tracks category
|
|
||||||
const tracks = await repo.getRecentlyPlayedAudio(5);
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
const trackWithImage = tracks.find((t: typeof tracks[0]) => t.primaryImageTag);
|
|
||||||
if (trackWithImage) {
|
|
||||||
categories = categories.map(cat =>
|
|
||||||
cat.id === "tracks"
|
|
||||||
? { ...cat, backgroundImage: trackWithImage.id }
|
|
||||||
: cat
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load category images:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageUrl(itemId: string | undefined) {
|
|
||||||
if (!itemId) return undefined;
|
|
||||||
return `http://tauri.localhost/image/primary/${itemId}?size=400&quality=95`;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadCategoryImages();
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleCategoryClick(route: string) {
|
function handleCategoryClick(route: string) {
|
||||||
goto(route);
|
goto(route);
|
||||||
}
|
}
|
||||||
@ -117,42 +71,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Grid -->
|
<!-- Category Grid -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||||
{#each categories as category (category.id)}
|
{#each categories as category (category.id)}
|
||||||
<button
|
<button
|
||||||
onclick={() => handleCategoryClick(category.route)}
|
onclick={() => handleCategoryClick(category.route)}
|
||||||
class="group relative bg-[var(--color-surface)] rounded-xl overflow-hidden hover:shadow-lg transition-all duration-200 text-left h-48"
|
class="group relative bg-[var(--color-surface)] rounded-xl p-8 hover:bg-[var(--color-surface-hover)] transition-all duration-200 text-left overflow-hidden"
|
||||||
style={category.backgroundImage ? `background-image: url('${getImageUrl(category.backgroundImage)}')` : ''}
|
|
||||||
>
|
>
|
||||||
<!-- Background image overlay -->
|
<!-- Background gradient -->
|
||||||
{#if category.backgroundImage}
|
<div
|
||||||
<div class="absolute inset-0 bg-black/40 group-hover:bg-black/50 transition-colors"></div>
|
class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
{:else}
|
></div>
|
||||||
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)]/20 to-transparent"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="relative z-10 h-full flex flex-col justify-between p-6">
|
<div class="relative z-10">
|
||||||
<!-- Icon and text section -->
|
|
||||||
<div>
|
|
||||||
<!-- Icon -->
|
<!-- Icon -->
|
||||||
<div class="w-14 h-14 mb-4 rounded-full bg-[var(--color-jellyfin)]/30 backdrop-blur-sm flex items-center justify-center group-hover:scale-110 transition-transform">
|
<div class="w-16 h-16 mb-4 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
<svg class="w-7 h-7 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d={category.icon} />
|
<path d={category.icon} />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Text -->
|
<!-- Text -->
|
||||||
<h2 class="text-xl font-bold text-white mb-1 group-hover:text-[var(--color-jellyfin)] transition-colors">
|
<h2 class="text-2xl font-bold text-white mb-2 group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||||
{category.name}
|
{category.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-gray-300 text-sm">
|
<p class="text-gray-400 text-sm">
|
||||||
{category.description}
|
{category.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Arrow indicator -->
|
<!-- Arrow indicator -->
|
||||||
<div class="flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="mt-4 flex items-center text-[var(--color-jellyfin)] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<span class="text-sm font-medium mr-1">Browse</span>
|
<span class="text-sm font-medium mr-1">Browse</span>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
|||||||
@ -16,24 +16,41 @@
|
|||||||
searchPlaceholder: "Search albums or artists...",
|
searchPlaceholder: "Search albums or artists...",
|
||||||
sortOptions: [
|
sortOptions: [
|
||||||
{
|
{
|
||||||
key: "SortName",
|
key: "name",
|
||||||
label: "A-Z",
|
label: "A-Z",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Artist",
|
key: "artist",
|
||||||
label: "Artist",
|
label: "Artist",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aArtist = a.artists?.[0] || "";
|
||||||
|
const bArtist = b.artists?.[0] || "";
|
||||||
|
return aArtist.localeCompare(bArtist);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ProductionYear",
|
key: "year",
|
||||||
label: "Year",
|
label: "Year",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aYear = a.productionYear || 0;
|
||||||
|
const bYear = b.productionYear || 0;
|
||||||
|
return bYear - aYear;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "DatePlayed",
|
key: "recent",
|
||||||
label: "Recent",
|
label: "Recent",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aDate = a.userData?.lastPlayedDate || "";
|
||||||
|
const bDate = b.userData?.lastPlayedDate || "";
|
||||||
|
return bDate.localeCompare(aDate);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultSort: "SortName",
|
defaultSort: "name",
|
||||||
displayComponent: "grid" as const,
|
displayComponent: "grid" as const,
|
||||||
|
searchFields: ["name", "artists"],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -16,16 +16,23 @@
|
|||||||
searchPlaceholder: "Search artists...",
|
searchPlaceholder: "Search artists...",
|
||||||
sortOptions: [
|
sortOptions: [
|
||||||
{
|
{
|
||||||
key: "SortName",
|
key: "name",
|
||||||
label: "A-Z",
|
label: "A-Z",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "DatePlayed",
|
key: "recent",
|
||||||
label: "Recent",
|
label: "Recent",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aDate = a.userData?.lastPlayedDate || "";
|
||||||
|
const bDate = b.userData?.lastPlayedDate || "";
|
||||||
|
return bDate.localeCompare(aDate);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultSort: "SortName",
|
defaultSort: "name",
|
||||||
displayComponent: "grid" as const,
|
displayComponent: "grid" as const,
|
||||||
|
searchFields: ["name"],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -16,24 +16,41 @@
|
|||||||
searchPlaceholder: "Search tracks or artists...",
|
searchPlaceholder: "Search tracks or artists...",
|
||||||
sortOptions: [
|
sortOptions: [
|
||||||
{
|
{
|
||||||
key: "SortName",
|
key: "title",
|
||||||
label: "Title",
|
label: "Title",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => a.name.localeCompare(b.name),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Artist",
|
key: "artist",
|
||||||
label: "Artist",
|
label: "Artist",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aArtist = a.artists?.[0] || "";
|
||||||
|
const bArtist = b.artists?.[0] || "";
|
||||||
|
return aArtist.localeCompare(bArtist);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Album",
|
key: "album",
|
||||||
label: "Album",
|
label: "Album",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aAlbum = a.album || "";
|
||||||
|
const bAlbum = b.album || "";
|
||||||
|
return aAlbum.localeCompare(bAlbum);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "DatePlayed",
|
key: "recent",
|
||||||
label: "Recent",
|
label: "Recent",
|
||||||
|
compareFn: (a: MediaItem, b: MediaItem) => {
|
||||||
|
const aDate = a.userData?.lastPlayedDate || "";
|
||||||
|
const bDate = b.userData?.lastPlayedDate || "";
|
||||||
|
return bDate.localeCompare(aDate);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultSort: "SortName",
|
defaultSort: "title",
|
||||||
displayComponent: "tracklist" as const,
|
displayComponent: "tracklist" as const,
|
||||||
|
searchFields: ["name", "artists", "album"],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user