Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75014ee00f | ||
|
|
342f95cac1 | ||
|
|
dcee342c47 | ||
|
|
78f5cd9db9 | ||
|
|
0eae81ec59 | ||
|
|
8eae4ae253 | ||
|
|
ef7be645b3 | ||
|
|
b9249f72e9 | ||
|
|
385d2270c9 | ||
|
|
345bd0730c | ||
|
|
e1e50d51e0 | ||
|
|
7d7f27aa10 | ||
|
|
f1d25c4f4d | ||
|
|
ff8f35084b | ||
|
|
4634ed595c | ||
|
|
6836ce79c8 | ||
|
|
2811e1b7ca | ||
|
|
1836615dc0 | ||
|
|
62874564ff | ||
|
|
17a35573a0 | ||
|
|
dcf08f30bc | ||
|
|
674c8e5cd0 | ||
|
|
45aa029916 | ||
|
|
3faa595b76 | ||
|
|
7fb866a583 | ||
|
|
0c3ed74fe1 | ||
|
|
a5b6266a6d | ||
|
|
a816c84f8c | ||
|
|
87762c03b6 | ||
|
|
975936b902 | ||
|
|
5ba9e0e958 | ||
|
|
ccc9dca924 | ||
|
|
60bb6c72d1 | ||
|
|
37cc9b424d | ||
|
|
2daee7ec2d | ||
|
|
01c6423154 | ||
|
|
d01c2aab9f | ||
|
|
14e9d7e03a | ||
|
|
76c78e2edc | ||
|
|
6146d70bc5 | ||
|
|
ea342d76e3 | ||
|
|
7d07b38d50 | ||
|
|
dc1f516da7 | ||
|
|
731966246f | ||
|
|
57ff364f54 | ||
|
|
ada3ed64ab | ||
|
|
55f1b85f12 | ||
|
|
26286ac6e7 | ||
|
|
144abb2f8b | ||
|
|
642ec17069 | ||
|
|
e560258a4b | ||
|
|
d1e5ba4c5d | ||
|
|
8ddad497bf | ||
|
|
46b9d328ab | ||
|
|
dd5d648d21 | ||
|
|
6866f03c55 | ||
|
|
0738ef10ec | ||
|
|
d5bca41c60 | ||
|
|
c959c07ab4 | ||
|
|
d6aa74fc85 | ||
|
|
09780103a7 | ||
|
|
3a9c126dfe | ||
|
|
c5be9eb18c | ||
|
|
e8e37649fa | ||
|
|
07f3bf04ca | ||
|
|
9594e963bc | ||
|
|
179c51a6fe | ||
|
|
59270e8a4f | ||
|
|
b8363aa993 | ||
|
|
410203a5b8 | ||
|
|
d199162dc6 | ||
|
|
27f5c435de | ||
|
|
cbd5435e26 | ||
|
|
a5e065daed | ||
|
|
8f1c4bc9da | ||
|
|
dbcaa1a1a5 | ||
|
|
e664bf4620 | ||
|
|
57f8a54dac | ||
|
|
e3797f32ca | ||
|
|
6d1c618a3a | ||
|
|
544ea43a84 |
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: '🏗️ Build and Test JellyTau'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- '**/*.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: linux/amd64
|
||||||
|
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-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
bunx svelte-kit sync
|
||||||
|
bun run test
|
||||||
|
|
||||||
|
- name: Run Rust tests
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
cargo test
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build Android APK
|
||||||
|
runs-on: linux/amd64
|
||||||
|
needs: test
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
NDK_VERSION: 27.0.11902837
|
||||||
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-android-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
bun install
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: bun run build
|
||||||
|
|
||||||
|
- name: Ensure Android NDK
|
||||||
|
run: |
|
||||||
|
if [ ! -d "$NDK_HOME" ]; then
|
||||||
|
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
|
||||||
|
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
|
||||||
|
fi
|
||||||
|
echo "Using NDK at $NDK_HOME"
|
||||||
|
ls "$NDK_HOME"
|
||||||
|
|
||||||
|
- name: Initialize Android project
|
||||||
|
run: |
|
||||||
|
cd src-tauri
|
||||||
|
echo "" | bunx tauri android init
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
- name: Build Android APK
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
bun run tauri android build --apk true --target aarch64
|
||||||
|
|
||||||
|
# Find the generated APK file
|
||||||
|
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
|
||||||
|
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Found artifact: ${ARTIFACT}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: jellytau-apk
|
||||||
|
path: ${{ steps.build.outputs.artifact }}
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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: linux/amd64
|
||||||
|
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-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
bunx svelte-kit sync
|
||||||
|
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: linux/amd64
|
||||||
|
needs: test
|
||||||
|
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-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
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: linux/amd64
|
||||||
|
needs: test
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-cargo-android-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Set app version from tag
|
||||||
|
run: |
|
||||||
|
REF="${GITHUB_REF#refs/tags/v}"
|
||||||
|
VERSION="${REF#refs/heads/}"
|
||||||
|
# On non-tag runs keep whatever is in tauri.conf.json
|
||||||
|
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||||
|
echo "Setting version to $VERSION"
|
||||||
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||||
|
fi
|
||||||
|
grep '"version"' src-tauri/tauri.conf.json
|
||||||
|
|
||||||
|
- name: Initialize Android project
|
||||||
|
run: bun run tauri android init
|
||||||
|
|
||||||
|
- name: Sync custom Android sources & gradle config
|
||||||
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
|
- name: Write signing keystore
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/jellytau-release.jks"
|
||||||
|
cat > src-tauri/gen/android/keystore.properties <<EOF
|
||||||
|
storeFile=$RUNNER_TEMP/jellytau-release.jks
|
||||||
|
storePassword=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
keyPassword=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build signed Android APK
|
||||||
|
run: bun run tauri android build --apk true --target aarch64
|
||||||
|
|
||||||
|
- name: Collect & verify signed APK
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/android
|
||||||
|
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-release.apk' | head -1)
|
||||||
|
if [ -z "$APK" ]; then echo "❌ No release APK produced"; exit 1; fi
|
||||||
|
cp "$APK" dist/android/jellytau-release.apk
|
||||||
|
APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1)
|
||||||
|
echo "🔏 Verifying signature with $APKSIGNER"
|
||||||
|
"$APKSIGNER" verify --print-certs dist/android/jellytau-release.apk
|
||||||
|
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: linux/amd64
|
||||||
|
needs: [build-linux, build-android]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
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: Publish Gitea release & upload assets
|
||||||
|
env:
|
||||||
|
# GITEA_TOKEN (a PAT) is preferred; falls back to the auto-provided token.
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
command -v jq >/dev/null || { echo "❌ jq is required on the runner"; exit 1; }
|
||||||
|
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||||
|
API="${GITHUB_SERVER_URL}/api/v1"
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||||
|
case "$VERSION" in *rc*|*beta*|*alpha*) PRE=true;; *) PRE=false;; esac
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg tag "$VERSION" \
|
||||||
|
--arg name "JellyTau $VERSION" \
|
||||||
|
--rawfile body release_notes.md \
|
||||||
|
--argjson pre "$PRE" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
||||||
|
|
||||||
|
echo "📦 Creating release $VERSION on $REPO"
|
||||||
|
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
||||||
|
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD")
|
||||||
|
if [ "$HTTP" = "201" ]; then
|
||||||
|
RELEASE_ID=$(jq -r '.id' resp.json)
|
||||||
|
elif [ "$HTTP" = "409" ]; then
|
||||||
|
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
||||||
|
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
||||||
|
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||||
|
else
|
||||||
|
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
|
||||||
|
fi
|
||||||
|
echo "Release id=$RELEASE_ID"
|
||||||
|
|
||||||
|
for f in artifacts/android/* artifacts/linux/*; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
echo "⬆️ Uploading $(basename "$f")"
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$f" >/dev/null
|
||||||
|
done
|
||||||
|
echo "✅ Release $VERSION published with assets"
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
name: Traceability Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-traces:
|
||||||
|
runs-on: linux/amd64
|
||||||
|
name: Check Requirement Traces
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|
||||||
|
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
|
||||||
|
# Pipe into the loop instead of a here-string (<<<) so this step works
|
||||||
|
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
|
||||||
|
# for the same reason. The loop runs in a subshell (so a counter var
|
||||||
|
# wouldn't survive), so we record warnings in a temp file and count it
|
||||||
|
# afterwards.
|
||||||
|
MISSING_FILE=$(mktemp)
|
||||||
|
echo "$CHANGED" | while IFS= read -r file; do
|
||||||
|
# Skip test files
|
||||||
|
case "$file" in
|
||||||
|
*.test.*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
if ! grep -q "TRACES:" "$file"; then
|
||||||
|
echo "⚠️ Missing TRACES: $file"
|
||||||
|
echo "$file" >> "$MISSING_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
|
||||||
|
rm -f "$MISSING_FILE"
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
name: Requirement Traceability Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
traceability:
|
||||||
|
name: Validate Requirement Traces
|
||||||
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v1
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Extract requirement traces
|
||||||
|
run: bun run traces:json > traces.json
|
||||||
|
|
||||||
|
- name: Validate trace format
|
||||||
|
run: |
|
||||||
|
if ! jq empty traces.json 2>/dev/null; then
|
||||||
|
echo "❌ Invalid traces.json format"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Traces JSON is valid"
|
||||||
|
|
||||||
|
- name: Check requirement coverage
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Extract coverage stats
|
||||||
|
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
||||||
|
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
||||||
|
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
||||||
|
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
||||||
|
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
||||||
|
|
||||||
|
echo "## 📊 Requirement Traceability Report"
|
||||||
|
echo ""
|
||||||
|
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
||||||
|
echo ""
|
||||||
|
echo "### Requirements Covered:"
|
||||||
|
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
||||||
|
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
||||||
|
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
||||||
|
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Set minimum coverage threshold (50%)
|
||||||
|
TOTAL_REQS=114
|
||||||
|
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
||||||
|
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
||||||
|
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
||||||
|
|
||||||
|
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
||||||
|
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check for new untraced code
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Find files modified in this PR/push
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||||
|
else
|
||||||
|
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
|
echo "✅ No source files changed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "### Files Changed:"
|
||||||
|
echo "$CHANGED_FILES" | sed 's/^/- /'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if changed files have TRACES
|
||||||
|
UNTRACED_FILES=""
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
# Skip test files and generated code
|
||||||
|
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if file has TRACES comments
|
||||||
|
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
||||||
|
UNTRACED_FILES+="$file"$'\n'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$CHANGED_FILES"
|
||||||
|
|
||||||
|
if [ -n "$UNTRACED_FILES" ]; then
|
||||||
|
echo "⚠️ New files without TRACES:"
|
||||||
|
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
||||||
|
echo ""
|
||||||
|
echo "💡 Add TRACES comments to link code to requirements:"
|
||||||
|
echo " // TRACES: UR-001, UR-002 | DR-003"
|
||||||
|
else
|
||||||
|
echo "✅ All changed files have TRACES comments"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate traceability report
|
||||||
|
if: always()
|
||||||
|
run: bun run traces:markdown
|
||||||
|
|
||||||
|
- name: Upload traceability report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: traceability-report
|
||||||
|
path: docs/traceability.md
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Comment PR with coverage report
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
||||||
|
|
||||||
|
const urCount = traces.byType.UR.length;
|
||||||
|
const irCount = traces.byType.IR.length;
|
||||||
|
const drCount = traces.byType.DR.length;
|
||||||
|
const jaCount = traces.byType.JA.length;
|
||||||
|
const total = urCount + irCount + drCount + jaCount;
|
||||||
|
const coverage = Math.round((total / 114) * 100);
|
||||||
|
|
||||||
|
const comment = `## 📊 Requirement Traceability Report
|
||||||
|
|
||||||
|
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
||||||
|
|
||||||
|
### By Type:
|
||||||
|
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
||||||
|
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
||||||
|
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
||||||
|
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
||||||
|
|
||||||
|
**Total Traces:** ${traces.totalTraces}
|
||||||
|
|
||||||
|
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
@@ -47,3 +52,9 @@ vite.config.ts.timestamp-*
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Android signing keystore (NEVER commit)
|
||||||
|
android-keystore/
|
||||||
|
|
||||||
|
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||||
|
src-tauri/.cargo/config.toml
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# 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 \
|
||||||
|
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
# mpv player library (linked via libmpv-sys)
|
||||||
|
libmpv-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 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..." && \
|
||||||
|
bunx svelte-kit sync && \
|
||||||
|
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/"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 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=36 \
|
||||||
|
BUILD_TOOLS_VERSION=35.0.0 \
|
||||||
|
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 \
|
||||||
|
jq \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
libclang-dev \
|
||||||
|
llvm-dev \
|
||||||
|
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
||||||
|
libglib2.0-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
libjavascriptcoregtk-4.1-dev \
|
||||||
|
libsoup-3.0-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
# mpv player library (linked via libmpv-sys)
|
||||||
|
libmpv-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
|
||||||
|
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
|
||||||
|
|
||||||
|
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
|
||||||
|
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||||
|
"platform-tools" \
|
||||||
|
"platforms;android-$SDK_VERSION" \
|
||||||
|
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||||
|
"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"]
|
||||||
@@ -1,324 +1,29 @@
|
|||||||
# JellyTau
|
<h1 align="center">
|
||||||
|
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
||||||
|
JellyTau
|
||||||
|
</h1>
|
||||||
|
|
||||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
|
||||||
|
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
|
||||||
|
Android (ExoPlayer).
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
## Getting Started
|
||||||
|
|
||||||
---
|
This project uses [bun](https://bun.sh) as its package manager.
|
||||||
|
|
||||||
# Requirements Specification
|
|
||||||
|
|
||||||
## 1. User Requirements
|
|
||||||
|
|
||||||
| ID | Requirement | Priority | Status |
|
|
||||||
|----|-------------|----------|--------|
|
|
||||||
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
|
||||||
| UR-002 | Access media when online or offline | High | Done |
|
|
||||||
| UR-003 | Play videos | High | Done |
|
|
||||||
| UR-004 | Play audio uninterrupted | High | Done |
|
|
||||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
|
||||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | In Progress |
|
|
||||||
| UR-007 | Navigate media in library | High | Done |
|
|
||||||
| UR-008 | Search media across libraries | High | Done |
|
|
||||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
|
||||||
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
|
||||||
| UR-011 | Download media on demand | Medium | Done |
|
|
||||||
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
|
||||||
| UR-013 | View and manage downloaded media | Medium | Done |
|
|
||||||
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned |
|
|
||||||
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
|
||||||
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
|
||||||
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
|
||||||
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
|
||||||
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
|
||||||
| UR-020 | Select subtitles for video content | High | Planned |
|
|
||||||
| UR-021 | Select audio track for video content | High | Planned |
|
|
||||||
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
|
||||||
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup | Medium | Done |
|
|
||||||
| UR-024 | View recently added content on server | Medium | Done |
|
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
|
||||||
| UR-026 | Sleep timer for audio playback | Low | Done |
|
|
||||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
|
||||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
|
||||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
|
||||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
|
||||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
|
||||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
|
||||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
|
||||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
|
||||||
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
|
||||||
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
|
||||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
|
||||||
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
|
||||||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Software Requirements
|
|
||||||
|
|
||||||
### 2.1 Integration Requirements
|
|
||||||
|
|
||||||
External system integrations and platform-specific implementations.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
|
||||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
|
||||||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
|
||||||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
|
||||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
|
||||||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
|
||||||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
|
||||||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
|
||||||
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
|
||||||
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
|
||||||
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
|
||||||
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
|
||||||
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
|
||||||
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
|
||||||
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
|
||||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
|
||||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Planned |
|
|
||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
|
||||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
|
||||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
|
||||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
|
||||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
|
||||||
|
|
||||||
API endpoints and data contracts required for Jellyfin integration.
|
|
||||||
|
|
||||||
| ID | Requirement | Endpoint Category | Traces To | Status |
|
|
||||||
|----|-------------|-------------------|-----------|--------|
|
|
||||||
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
|
||||||
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
|
||||||
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
|
||||||
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
|
||||||
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
|
||||||
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
|
||||||
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
|
||||||
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Planned |
|
|
||||||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
|
|
||||||
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
|
||||||
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
|
||||||
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
|
||||||
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
|
||||||
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
|
||||||
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
|
||||||
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
|
||||||
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
|
||||||
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
|
||||||
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
|
|
||||||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
|
|
||||||
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
|
|
||||||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
|
|
||||||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
|
|
||||||
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
|
||||||
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
|
||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Planned |
|
|
||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
|
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
|
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
|
||||||
|
|
||||||
Internal architecture, components, and application logic.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
|
||||||
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
|
||||||
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
|
||||||
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
|
||||||
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
|
||||||
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
|
||||||
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
|
||||||
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
|
||||||
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
|
||||||
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
|
||||||
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
|
||||||
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Planned |
|
|
||||||
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
|
||||||
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
|
||||||
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
|
||||||
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
|
||||||
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned |
|
|
||||||
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
|
||||||
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
|
||||||
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
|
||||||
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Planned |
|
|
||||||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
|
|
||||||
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
|
||||||
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
|
||||||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
|
||||||
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
|
||||||
| DR-029 | Sleep timer with countdown and auto-stop | Player | UR-026 | Done |
|
|
||||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
|
||||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
|
||||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
|
||||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
|
||||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
|
||||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
|
||||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
|
||||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
|
||||||
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
|
||||||
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
|
||||||
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
|
||||||
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
|
||||||
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
|
||||||
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
|
||||||
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
|
||||||
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
|
||||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
|
||||||
| DR-047 | Next episode auto-play popup with configurable countdown | Player | UR-023 | Done |
|
|
||||||
| DR-048 | Video settings (auto-play toggle, countdown duration) | Settings | UR-023, UR-026 | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Traceability Matrix
|
|
||||||
|
|
||||||
### User Requirements to Software Requirements
|
|
||||||
|
|
||||||
| User Req | Integration Requirements | Development Requirements |
|
|
||||||
|----------|-------------------------|-------------------------|
|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
|
||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
|
||||||
| UR-010 | IR-012, IR-021 | DR-037 |
|
|
||||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
|
||||||
| UR-012 | IR-009, IR-014 | - |
|
|
||||||
| UR-013 | IR-013 | DR-017 |
|
|
||||||
| UR-014 | - | DR-014, DR-019 |
|
|
||||||
| UR-015 | - | DR-005, DR-020 |
|
|
||||||
| UR-016 | - | - |
|
|
||||||
| UR-017 | - | DR-014, DR-021 |
|
|
||||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
|
||||||
| UR-019 | IR-015 | DR-022 |
|
|
||||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
|
||||||
| UR-022 | IR-017 | DR-025 |
|
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048 |
|
|
||||||
| UR-024 | IR-010 | DR-027 |
|
|
||||||
| UR-025 | IR-015 | DR-028 |
|
|
||||||
| UR-026 | - | DR-029, DR-048 |
|
|
||||||
| UR-027 | IR-020 | DR-030 |
|
|
||||||
| UR-028 | - | DR-031 |
|
|
||||||
| UR-029 | - | DR-032 |
|
|
||||||
| UR-030 | IR-010 | DR-033 |
|
|
||||||
| UR-031 | - | DR-034 |
|
|
||||||
| UR-032 | - | DR-035 |
|
|
||||||
| UR-033 | - | DR-036 |
|
|
||||||
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
|
||||||
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
|
||||||
| UR-036 | IR-022, IR-023 | DR-041 |
|
|
||||||
| UR-037 | IR-010 | DR-042 |
|
|
||||||
| UR-038 | IR-010 | DR-043 |
|
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Test Traceability
|
|
||||||
|
|
||||||
### Unit Tests to Software Requirements
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| UT-001 | Player state transitions | DR-001 | Pending |
|
|
||||||
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
|
||||||
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
|
||||||
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
|
||||||
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
|
||||||
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
|
||||||
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
|
||||||
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
|
||||||
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
|
||||||
| UT-010 | Download queue management | DR-015 | Done |
|
|
||||||
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
|
||||||
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
|
||||||
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
|
||||||
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
|
||||||
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
|
||||||
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
|
||||||
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
|
||||||
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
|
||||||
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
|
||||||
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
|
||||||
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
|
||||||
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
|
||||||
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
|
||||||
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
|
||||||
| UT-027 | NullBackend set volume | DR-004 | Done |
|
|
||||||
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
|
||||||
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
|
||||||
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
|
||||||
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
|
||||||
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
|
||||||
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
|
||||||
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
|
||||||
| UT-035 | Download event completed serialization | DR-015 | Done |
|
|
||||||
| UT-036 | Download event failed serialization | DR-015 | Done |
|
|
||||||
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
|
||||||
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
|
||||||
| UT-039 | Download manager creation | DR-015 | Done |
|
|
||||||
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
|
||||||
| UT-041 | Download info serialization | DR-015 | Done |
|
|
||||||
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
|
||||||
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
|
||||||
| UT-044 | Offline item serialization | DR-017 | Done |
|
|
||||||
| UT-045 | Smart cache default config | DR-015 | Done |
|
|
||||||
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
|
||||||
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
|
||||||
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
|
||||||
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
|
||||||
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
|
||||||
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
|
||||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
|
||||||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
|
||||||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
|
||||||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
|
||||||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
|
||||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
|
||||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Development Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activate Rust environment (fish shell)
|
# Activate the Rust environment (fish shell)
|
||||||
source "$HOME/.cargo/env.fish"
|
source "$HOME/.cargo/env.fish"
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Development
|
# Run in development
|
||||||
bun run tauri dev
|
bun run tauri dev
|
||||||
|
|
||||||
# Type checking
|
# Type-check the frontend
|
||||||
bun run check
|
bun run check
|
||||||
|
|
||||||
# Build for Linux
|
# Build for Linux
|
||||||
@@ -328,168 +33,28 @@ bun run tauri build
|
|||||||
bun run tauri android build
|
bun run tauri android build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
For the full set of build, test, and Android helper scripts, see
|
||||||
|
[scripts/README.md](scripts/README.md).
|
||||||
|
|
||||||
## 6. Architecture Overview
|
## Documentation
|
||||||
|
|
||||||
```
|
| Topic | Location |
|
||||||
jellytau/
|
|-------|----------|
|
||||||
├── src/ # Svelte frontend
|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||||
│ ├── lib/
|
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||||
│ │ ├── api/ # Jellyfin API client (repository pattern)
|
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
||||||
│ │ ├── components/ # UI components (player, library)
|
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||||
│ │ └── stores/ # Svelte stores (auth, library, player, queue)
|
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||||
│ └── routes/ # SvelteKit pages
|
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||||
├── src-tauri/ # Rust backend
|
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── commands/ # Tauri commands
|
|
||||||
│ │ └── player/ # Player architecture
|
|
||||||
│ │ ├── state.rs # State machine
|
|
||||||
│ │ ├── media.rs # MediaItem, MediaSource
|
|
||||||
│ │ ├── queue.rs # Queue management
|
|
||||||
│ │ └── backend.rs # PlayerBackend trait
|
|
||||||
│ └── gen/android/ # Android project
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Recommended IDE Setup
|
||||||
|
|
||||||
## 7. Technical Debt
|
[VS Code](https://code.visualstudio.com/) +
|
||||||
|
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
|
||||||
|
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
|
||||||
|
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||||
|
|
||||||
### Linux Keyring Integration Workaround
|
## License
|
||||||
|
|
||||||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
MIT
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
|
||||||
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
|
||||||
- Session restoration fails on app restart even though credentials exist
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
|
||||||
|
|
||||||
**Current Workaround**:
|
|
||||||
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
|
||||||
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
|
||||||
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
|
||||||
- **Delete**: `secret-tool clear service <service> username <username>`
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
See [src-tauri/src/credentials.rs](src-tauri/src/credentials.rs):
|
|
||||||
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
|
|
||||||
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
|
|
||||||
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
- Monitor `keyring-rs` for bug fixes in future versions
|
|
||||||
- Consider alternative secure storage libraries
|
|
||||||
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
|
||||||
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - The workaround is functionally equivalent to proper keyring integration
|
|
||||||
- Credentials are stored securely in the system keyring
|
|
||||||
- Session restoration works correctly
|
|
||||||
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
|
||||||
- Already available on most Linux distributions by default
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Platform Playback Backend Parity (Linux vs Android)
|
|
||||||
|
|
||||||
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
|
||||||
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
|
||||||
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
|
||||||
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
|
|
||||||
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
|
||||||
|
|
||||||
**Feature Parity Matrix**:
|
|
||||||
|
|
||||||
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
|
||||||
|---------|-------------|---------------------|--------|
|
|
||||||
| Basic playback | ✅ | ✅ | Parity |
|
|
||||||
| Volume control | ✅ | ✅ | Parity |
|
|
||||||
| Seek | ✅ | ✅ | Parity |
|
|
||||||
| Crossfade | ✅ | ❌ | Gap |
|
|
||||||
| Gapless playback | ✅ | ❌ | Gap |
|
|
||||||
| Volume normalization | ✅ | ❌ | Gap |
|
|
||||||
| Position updates | 250ms | On-demand | Inconsistent |
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
|
||||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
|
||||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
|
||||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
|
||||||
5. Standardize position update frequency across platforms
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
|
||||||
- User experience differs between platforms
|
|
||||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
|
||||||
|
|
||||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend Playback Code Duplication
|
|
||||||
|
|
||||||
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
|
||||||
- Same `$derived` state merging logic for local/remote playback in both components
|
|
||||||
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
|
|
||||||
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
|
|
||||||
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
|
|
||||||
|
|
||||||
**Duplicated Code**:
|
|
||||||
```typescript
|
|
||||||
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
|
||||||
handlePlayPause(), handleNext(), handlePrevious(),
|
|
||||||
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
|
||||||
|
|
||||||
// These derived states use identical logic:
|
|
||||||
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Create `src/lib/utils/playbackUnits.ts`:
|
|
||||||
```typescript
|
|
||||||
export const TICKS_PER_SECOND = 10_000_000;
|
|
||||||
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
|
||||||
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
|
||||||
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
|
||||||
- Single source of truth for merged local/remote state
|
|
||||||
|
|
||||||
3. Simplify handler wrappers using a utility:
|
|
||||||
```typescript
|
|
||||||
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
|
||||||
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - Code works correctly but violates DRY principle
|
|
||||||
- Maintenance burden when logic needs to change
|
|
||||||
- Risk of handlers diverging over time
|
|
||||||
|
|
||||||
**Traces To**: DR-009
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 run 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:
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
# Rust Backend Architecture
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/`
|
||||||
|
|
||||||
|
## Media Session State Machine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/session.rs`
|
||||||
|
|
||||||
|
The media session tracks the high-level playback context (what kind of media is being consumed) and persists beyond individual playback states. This enables persistent UI (miniplayer for audio) and proper transitions between content types.
|
||||||
|
|
||||||
|
**Architecture Note:** The session manager is a separate app-level state manager (not inside PlayerController), coordinated by the commands layer. This maintains clean separation of concerns.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
|
||||||
|
Idle --> AudioActive : play_queue(audio)
|
||||||
|
Idle --> MovieActive : play_item(movie)
|
||||||
|
Idle --> TvShowActive : play_item(episode)
|
||||||
|
|
||||||
|
state "Audio Session" as AudioSession {
|
||||||
|
[*] --> AudioActive
|
||||||
|
AudioActive --> AudioInactive : playback_ended
|
||||||
|
AudioInactive --> AudioActive : resume/play
|
||||||
|
AudioActive --> AudioActive : next/previous
|
||||||
|
}
|
||||||
|
|
||||||
|
state "Movie Session" as MovieSession {
|
||||||
|
[*] --> MovieActive
|
||||||
|
MovieActive --> MovieInactive : playback_ended
|
||||||
|
MovieInactive --> MovieActive : resume
|
||||||
|
}
|
||||||
|
|
||||||
|
state "TV Show Session" as TvShowSession {
|
||||||
|
[*] --> TvShowActive
|
||||||
|
TvShowActive --> TvShowInactive : playback_ended
|
||||||
|
TvShowInactive --> TvShowActive : next_episode/resume
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioSession --> Idle : dismiss/clear_queue
|
||||||
|
AudioSession --> MovieSession : play_item(movie)
|
||||||
|
AudioSession --> TvShowSession : play_item(episode)
|
||||||
|
|
||||||
|
MovieSession --> Idle : dismiss/playback_complete
|
||||||
|
MovieSession --> AudioSession : play_queue(audio)
|
||||||
|
|
||||||
|
TvShowSession --> Idle : dismiss/series_complete
|
||||||
|
TvShowSession --> AudioSession : play_queue(audio)
|
||||||
|
|
||||||
|
note right of Idle
|
||||||
|
No active media session
|
||||||
|
Queue may exist but not playing
|
||||||
|
No miniplayer/video player shown
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of AudioSession
|
||||||
|
SHOW: Miniplayer (always visible)
|
||||||
|
- Active: Play/pause/skip controls enabled
|
||||||
|
- Inactive: Play button to resume queue
|
||||||
|
Persists until explicit dismiss
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of MovieSession
|
||||||
|
SHOW: Full video player
|
||||||
|
- Active: Video playing/paused
|
||||||
|
- Inactive: Resume dialog
|
||||||
|
Auto-dismiss when playback ends
|
||||||
|
end note
|
||||||
|
|
||||||
|
note right of TvShowSession
|
||||||
|
SHOW: Full video player + Next Episode UI
|
||||||
|
- Active: Video playing/paused
|
||||||
|
- Inactive: Next episode prompt
|
||||||
|
Auto-dismiss when series ends
|
||||||
|
end note
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session State Enum:**
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum MediaSessionType {
|
||||||
|
/// No active session - browsing library
|
||||||
|
Idle,
|
||||||
|
|
||||||
|
/// Audio playback session (music, audiobooks, podcasts)
|
||||||
|
/// Persists until explicitly dismissed
|
||||||
|
Audio {
|
||||||
|
/// Last/current track being played
|
||||||
|
last_item: Option<MediaItem>,
|
||||||
|
/// True = playing/paused, False = stopped/ended
|
||||||
|
is_active: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Movie playback (single video, auto-dismiss on end)
|
||||||
|
Movie {
|
||||||
|
item: MediaItem,
|
||||||
|
is_active: bool, // true = playing/paused, false = ended
|
||||||
|
},
|
||||||
|
|
||||||
|
/// TV show playback (supports next episode auto-advance)
|
||||||
|
TvShow {
|
||||||
|
item: MediaItem,
|
||||||
|
series_id: String,
|
||||||
|
is_active: bool, // true = playing/paused, false = ended
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Transitions & Rules:**
|
||||||
|
|
||||||
|
| From State | Event | To State | UI Behavior | Notes |
|
||||||
|
|------------|-------|----------|-------------|-------|
|
||||||
|
| Idle | `play_queue(audio)` | Audio (active) | Show miniplayer | Creates audio session |
|
||||||
|
| Idle | `play_item(movie)` | Movie (active) | Show video player | Creates movie session |
|
||||||
|
| Idle | `play_item(episode)` | TvShow (active) | Show video player | Creates TV session |
|
||||||
|
| Audio (active) | `playback_ended` | Audio (inactive) | Miniplayer stays visible | Queue preserved |
|
||||||
|
| Audio (inactive) | `play/resume` | Audio (active) | Miniplayer enabled | Resume from queue |
|
||||||
|
| Audio (active/inactive) | `dismiss` | Idle | Hide miniplayer | Clear session |
|
||||||
|
| Audio (active/inactive) | `play_item(movie)` | Movie (active) | Switch to video player | Replace session |
|
||||||
|
| Movie (active) | `playback_ended` | Idle | Hide video player | Auto-dismiss |
|
||||||
|
| Movie (active) | `dismiss` | Idle | Hide video player | User dismiss |
|
||||||
|
| TvShow (active) | `playback_ended` | TvShow (inactive) | Show next episode UI | Wait for user choice |
|
||||||
|
| TvShow (inactive) | `next_episode` | TvShow (active) | Play next episode | Stay in session |
|
||||||
|
| TvShow (inactive) | `series_complete` | Idle | Hide video player | No more episodes |
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
1. **Audio Sessions Persist**: Miniplayer stays visible even when queue ends, allows easy resume
|
||||||
|
2. **Video Sessions Auto-Dismiss**: Movies auto-close when finished (unless paused)
|
||||||
|
3. **Single Active Session**: Playing new content type replaces current session
|
||||||
|
4. **Explicit Dismiss for Audio**: User must click close button to clear audio session
|
||||||
|
5. **Session != PlayerState**: Session is higher-level, PlayerState tracks playing/paused/seeking
|
||||||
|
|
||||||
|
**Edge Cases Handled:**
|
||||||
|
|
||||||
|
- Album finishes: Session goes inactive, miniplayer shows last track with play disabled
|
||||||
|
- User wants to dismiss: Close button clears session -> Idle
|
||||||
|
- Switch content types: New session replaces old (audio -> movie)
|
||||||
|
- Paused for extended time: Session persists indefinitely
|
||||||
|
- Playback errors: Session stays inactive, allows retry
|
||||||
|
- Queue operations while idle: Queue exists but no session created until play
|
||||||
|
|
||||||
|
## Player State Machine (Low-Level Playback)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/state.rs`
|
||||||
|
|
||||||
|
The player uses a deterministic state machine with 6 states (operates within a media session):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
Idle --> Loading : Load
|
||||||
|
Loading --> Playing : MediaLoaded
|
||||||
|
Playing --> Paused : Pause
|
||||||
|
Paused --> Playing : Play
|
||||||
|
Paused --> Seeking : Seek
|
||||||
|
Seeking --> Playing : PositionUpdate
|
||||||
|
Playing --> Idle : Stop
|
||||||
|
Paused --> Idle : Stop
|
||||||
|
Idle --> Error : Error
|
||||||
|
Loading --> Error : Error
|
||||||
|
Playing --> Error : Error
|
||||||
|
Paused --> Error : Error
|
||||||
|
Seeking --> Error : Error
|
||||||
|
|
||||||
|
state Playing {
|
||||||
|
[*] : position, duration
|
||||||
|
}
|
||||||
|
state Paused {
|
||||||
|
[*] : position, duration
|
||||||
|
}
|
||||||
|
state Seeking {
|
||||||
|
[*] : target
|
||||||
|
}
|
||||||
|
state Error {
|
||||||
|
[*] : error message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerState {
|
||||||
|
Idle,
|
||||||
|
Loading { media: MediaItem },
|
||||||
|
Playing { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Paused { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Seeking { media: MediaItem, target: f64 },
|
||||||
|
Error { media: Option<MediaItem>, error: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerEvent {
|
||||||
|
Load(MediaItem),
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
Seek(f64),
|
||||||
|
Next,
|
||||||
|
Previous,
|
||||||
|
MediaLoaded(f64), // duration
|
||||||
|
PositionUpdate(f64), // position
|
||||||
|
PlaybackEnded,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback Mode State Machine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
||||||
|
|
||||||
|
The playback mode manages whether media is playing locally on the device or remotely on another Jellyfin session (TV, browser, etc.):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Idle
|
||||||
|
|
||||||
|
Idle --> Local : play_queue()
|
||||||
|
Idle --> Remote : transfer_to_remote(session_id)
|
||||||
|
|
||||||
|
Local --> Remote : transfer_to_remote(session_id)
|
||||||
|
Local --> Idle : stop()
|
||||||
|
|
||||||
|
Remote --> Local : transfer_to_local()
|
||||||
|
Remote --> Idle : session_disconnected()
|
||||||
|
Remote --> Idle : stop()
|
||||||
|
|
||||||
|
state Local {
|
||||||
|
[*] : Playing on device
|
||||||
|
[*] : ExoPlayer active
|
||||||
|
[*] : Volume buttons -> device
|
||||||
|
}
|
||||||
|
|
||||||
|
state Remote {
|
||||||
|
[*] : Controlling session
|
||||||
|
[*] : session_id
|
||||||
|
[*] : Volume buttons -> remote
|
||||||
|
[*] : Android: VolumeProvider active
|
||||||
|
}
|
||||||
|
|
||||||
|
state Idle {
|
||||||
|
[*] : No active playback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Enum:**
|
||||||
|
```rust
|
||||||
|
pub enum PlaybackMode {
|
||||||
|
Local, // Playing on local device
|
||||||
|
Remote { session_id: String }, // Controlling remote Jellyfin session
|
||||||
|
Idle, // No active playback
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Transitions:**
|
||||||
|
|
||||||
|
| From | Event | To | Side Effects |
|
||||||
|
|------|-------|-----|----|
|
||||||
|
| Idle | `play_queue()` | Local | Start local playback |
|
||||||
|
| Idle | `transfer_to_remote(session_id)` | Remote | Send queue to remote session |
|
||||||
|
| Local | `transfer_to_remote(session_id)` | Remote | Stop local, send queue to remote, enable remote volume (Android) |
|
||||||
|
| Local | `stop()` | Idle | Stop local playback |
|
||||||
|
| Remote | `transfer_to_local()` | Local | Get remote state, stop remote, start local at same position, disable remote volume |
|
||||||
|
| Remote | `stop()` | Idle | Stop remote playback, disable remote volume |
|
||||||
|
| Remote | `session_disconnected()` | Idle | Session lost, disable remote volume |
|
||||||
|
|
||||||
|
**Integration with Player State Machine:**
|
||||||
|
|
||||||
|
- When `PlaybackMode = Local`: Player state machine is active (Idle/Loading/Playing/Paused/etc.)
|
||||||
|
- When `PlaybackMode = Remote`: Player state is typically Idle (remote session controls playback)
|
||||||
|
- When `PlaybackMode = Idle`: Player state is Idle
|
||||||
|
|
||||||
|
**Android Volume Control Integration:**
|
||||||
|
|
||||||
|
When transitioning to `Remote` mode on Android:
|
||||||
|
1. Call `enable_remote_volume(initial_volume)`
|
||||||
|
2. VolumeProviderCompat intercepts hardware volume buttons
|
||||||
|
3. PlaybackStateCompat is set to STATE_PLAYING (shows volume UI)
|
||||||
|
4. Volume commands routed to remote session via Jellyfin API
|
||||||
|
|
||||||
|
When transitioning away from `Remote` mode:
|
||||||
|
1. Call `disable_remote_volume()`
|
||||||
|
2. Volume buttons return to controlling device volume
|
||||||
|
3. PlaybackStateCompat set to STATE_NONE
|
||||||
|
4. VolumeProviderCompat is cleared
|
||||||
|
|
||||||
|
## Media Item & Source
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/media.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub artwork_url: Option<String>,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub source: MediaSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MediaType {
|
||||||
|
Audio,
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum MediaSource {
|
||||||
|
Remote {
|
||||||
|
stream_url: String,
|
||||||
|
jellyfin_item_id: String,
|
||||||
|
},
|
||||||
|
Local {
|
||||||
|
file_path: PathBuf,
|
||||||
|
jellyfin_item_id: Option<String>,
|
||||||
|
},
|
||||||
|
DirectUrl {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `MediaSource` enum enables:
|
||||||
|
- **Remote**: Streaming from Jellyfin server
|
||||||
|
- **Local**: Downloaded/cached files (future offline support)
|
||||||
|
- **DirectUrl**: Direct URLs (channel plugins, external sources)
|
||||||
|
|
||||||
|
## Queue Manager
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/queue.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct QueueManager {
|
||||||
|
items: Vec<MediaItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
shuffle: bool,
|
||||||
|
repeat: RepeatMode,
|
||||||
|
shuffle_order: Vec<usize>, // Fisher-Yates permutation
|
||||||
|
history: Vec<usize>, // For back navigation in shuffle
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum RepeatMode {
|
||||||
|
Off,
|
||||||
|
All,
|
||||||
|
One,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queue Navigation Logic:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
QM[QueueManager]
|
||||||
|
QM --> Shuffle
|
||||||
|
QM --> Repeat
|
||||||
|
QM --> History
|
||||||
|
|
||||||
|
subgraph Shuffle["Shuffle Mode"]
|
||||||
|
ShuffleOff["OFF<br/>next() returns index + 1"]
|
||||||
|
ShuffleOn["ON<br/>next() follows shuffle_order[]"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Repeat["Repeat Mode"]
|
||||||
|
RepeatOff["OFF<br/>next() at end: -> None"]
|
||||||
|
RepeatAll["ALL<br/>next() at end: -> wrap to index 0"]
|
||||||
|
RepeatOne["ONE<br/>next() returns same item"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph History["History"]
|
||||||
|
HistoryDesc["Used for previous()<br/>in shuffle mode"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Favorites System
|
||||||
|
|
||||||
|
**Location**:
|
||||||
|
- Service: `src/lib/services/favorites.ts`
|
||||||
|
- Component: `src/lib/components/FavoriteButton.svelte`
|
||||||
|
- Backend: `src-tauri/src/commands/storage.rs`
|
||||||
|
|
||||||
|
The favorites system implements optimistic updates with server synchronization:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
UI[FavoriteButton] -->|Click| Service[toggleFavorite]
|
||||||
|
Service -->|1. Optimistic| LocalDB[(SQLite user_data)]
|
||||||
|
Service -->|2. Sync| JellyfinAPI[Jellyfin API]
|
||||||
|
Service -->|3. Mark Synced| LocalDB
|
||||||
|
|
||||||
|
JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
||||||
|
JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"]
|
||||||
|
|
||||||
|
LocalDB -->|is_favorite<br/>pending_sync| UserData[user_data table]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow**:
|
||||||
|
1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages)
|
||||||
|
2. `toggleFavorite()` service function handles the logic:
|
||||||
|
- Updates local SQLite database immediately (optimistic update)
|
||||||
|
- Attempts to sync with Jellyfin server
|
||||||
|
- Marks as synced if successful, otherwise leaves `pending_sync = 1`
|
||||||
|
3. UI reflects the change immediately without waiting for server response
|
||||||
|
|
||||||
|
**Components**:
|
||||||
|
|
||||||
|
- **FavoriteButton.svelte**: Reusable heart button component
|
||||||
|
- Configurable size (sm/md/lg)
|
||||||
|
- Red when favorited, gray when not
|
||||||
|
- Loading state during toggle
|
||||||
|
- Bindable `isFavorite` prop for two-way binding
|
||||||
|
|
||||||
|
- **Integration Points**:
|
||||||
|
- MiniPlayer: Shows favorite button for audio tracks (hidden on small screens)
|
||||||
|
- Full AudioPlayer: Shows favorite button (planned)
|
||||||
|
- Album/Artist detail pages: Shows favorite button (planned)
|
||||||
|
|
||||||
|
**Database Schema**:
|
||||||
|
- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1)
|
||||||
|
- `user_data.pending_sync`: Indicates if local changes need syncing
|
||||||
|
|
||||||
|
**Tauri Commands**:
|
||||||
|
- `storage_toggle_favorite`: Updates favorite status in local database
|
||||||
|
- `storage_mark_synced`: Clears pending_sync flag after successful sync
|
||||||
|
|
||||||
|
**API Methods**:
|
||||||
|
- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin
|
||||||
|
- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin
|
||||||
|
|
||||||
|
## Player Backend Trait
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/backend.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait PlayerBackend: Send + Sync {
|
||||||
|
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError>;
|
||||||
|
fn play(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn pause(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn stop(&mut self) -> Result<(), PlayerError>;
|
||||||
|
fn seek(&mut self, position: f64) -> Result<(), PlayerError>;
|
||||||
|
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError>;
|
||||||
|
fn position(&self) -> f64;
|
||||||
|
fn duration(&self) -> Option<f64>;
|
||||||
|
fn state(&self) -> PlayerState;
|
||||||
|
fn is_loaded(&self) -> bool;
|
||||||
|
fn volume(&self) -> f32;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementations:**
|
||||||
|
- `NullBackend` - Mock backend for testing
|
||||||
|
- `MpvBackend` - Linux playback via libmpv (see [05-platform-backends.md](05-platform-backends.md))
|
||||||
|
- `ExoPlayerBackend` - Android playback via ExoPlayer/Media3 (see [05-platform-backends.md](05-platform-backends.md))
|
||||||
|
|
||||||
|
## Player Controller
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mod.rs`
|
||||||
|
|
||||||
|
The `PlayerController` orchestrates playback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PlayerController {
|
||||||
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||||
|
queue: Arc<Mutex<QueueManager>>,
|
||||||
|
muted: bool,
|
||||||
|
sleep_timer: Arc<Mutex<SleepTimerState>>,
|
||||||
|
autoplay_settings: Arc<Mutex<AutoplaySettings>>,
|
||||||
|
autoplay_episode_count: Arc<Mutex<u32>>, // Session-based counter
|
||||||
|
repository: Arc<Mutex<Option<Arc<dyn MediaRepository>>>>,
|
||||||
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||||
|
// ... other fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `play_item(item)`: Load and play single item (resets autoplay counter)
|
||||||
|
- `play_queue(items, start_index)`: Load queue and start playback (resets autoplay counter)
|
||||||
|
- `next()` / `previous()`: Queue navigation (resets autoplay counter)
|
||||||
|
- `toggle_shuffle()` / `cycle_repeat()`: Mode changes
|
||||||
|
- `set_sleep_timer(mode)` / `cancel_sleep_timer()`: Sleep timer control
|
||||||
|
- `on_playback_ended()`: Autoplay decision making (checks sleep timer, episode limit, queue)
|
||||||
|
|
||||||
|
## Playlist System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/playlist.rs`, `src-tauri/src/repository/`
|
||||||
|
|
||||||
|
**TRACES**: UR-014 | JA-019 | JA-020
|
||||||
|
|
||||||
|
The playlist system provides full CRUD operations for Jellyfin playlists with offline support through the cache-first repository pattern.
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// A media item within a playlist, with its distinct playlist entry ID
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistEntry {
|
||||||
|
/// Jellyfin's PlaylistItemId (distinct from the media item ID)
|
||||||
|
pub playlist_item_id: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub item: MediaItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of creating a new playlist
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlaylistCreatedResult {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decision**: `PlaylistEntry` wraps a `MediaItem` with a distinct `playlist_item_id`. This is critical because removing items from a playlist requires the playlist entry ID (not the media item ID), since the same track can appear multiple times.
|
||||||
|
|
||||||
|
**MediaRepository Trait Methods:**
|
||||||
|
```rust
|
||||||
|
async fn create_playlist(&self, name: &str, item_ids: Option<Vec<String>>) -> Result<PlaylistCreatedResult, RepoError>;
|
||||||
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError>;
|
||||||
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError>;
|
||||||
|
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError>;
|
||||||
|
async fn add_to_playlist(&self, playlist_id: &str, item_ids: Vec<String>) -> Result<(), RepoError>;
|
||||||
|
async fn remove_from_playlist(&self, playlist_id: &str, entry_ids: Vec<String>) -> Result<(), RepoError>;
|
||||||
|
async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: u32) -> Result<(), RepoError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache Strategy:**
|
||||||
|
- **Write operations** (create, delete, rename, add, remove, move): Delegate directly to online repository
|
||||||
|
- **Read operation** (`get_playlist_items`): Uses cache-first parallel racing (100ms cache timeout, server fallback)
|
||||||
|
- Background cache update after server fetch via `save_playlist_items_to_cache()`
|
||||||
|
|
||||||
|
**Playlist Tauri Commands:**
|
||||||
|
|
||||||
|
| Command | Parameters | Returns |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| `playlist_create` | `handle, name, item_ids?` | `PlaylistCreatedResult` |
|
||||||
|
| `playlist_delete` | `handle, playlist_id` | `()` |
|
||||||
|
| `playlist_rename` | `handle, playlist_id, name` | `()` |
|
||||||
|
| `playlist_get_items` | `handle, playlist_id` | `Vec<PlaylistEntry>` |
|
||||||
|
| `playlist_add_items` | `handle, playlist_id, item_ids` | `()` |
|
||||||
|
| `playlist_remove_items` | `handle, playlist_id, entry_ids` | `()` |
|
||||||
|
| `playlist_move_item` | `handle, playlist_id, item_id, new_index` | `()` |
|
||||||
|
|
||||||
|
## Tauri Commands (Player)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/player.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Returns |
|
||||||
|
|---------|------------|---------|
|
||||||
|
| `player_play_item` | `PlayItemRequest` | `PlayerStatus` |
|
||||||
|
| `player_play_queue` | `items, start_index, shuffle` | `PlayerStatus` |
|
||||||
|
| `player_play` | - | `PlayerStatus` |
|
||||||
|
| `player_pause` | - | `PlayerStatus` |
|
||||||
|
| `player_toggle` | - | `PlayerStatus` |
|
||||||
|
| `player_stop` | - | `PlayerStatus` |
|
||||||
|
| `player_next` | - | `PlayerStatus` |
|
||||||
|
| `player_previous` | - | `PlayerStatus` |
|
||||||
|
| `player_seek` | `position: f64` | `PlayerStatus` |
|
||||||
|
| `player_set_volume` | `volume: f32` | `PlayerStatus` |
|
||||||
|
| `player_toggle_shuffle` | - | `QueueStatus` |
|
||||||
|
| `player_cycle_repeat` | - | `QueueStatus` |
|
||||||
|
| `player_get_status` | - | `PlayerStatus` |
|
||||||
|
| `player_get_queue` | - | `QueueStatus` |
|
||||||
|
| `player_get_session` | - | `MediaSessionType` |
|
||||||
|
| `player_dismiss_session` | - | `()` |
|
||||||
|
| `player_set_sleep_timer` | `mode: SleepTimerMode` | `()` |
|
||||||
|
| `player_cancel_sleep_timer` | - | `()` |
|
||||||
|
| `player_set_video_settings` | `settings: VideoSettings` | `VideoSettings` |
|
||||||
|
| `player_get_video_settings` | - | `VideoSettings` |
|
||||||
|
| `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` |
|
||||||
|
| `player_get_autoplay_settings` | - | `AutoplaySettings` |
|
||||||
|
| `player_on_playback_ended` | - | `()` |
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
# Svelte Frontend Architecture
|
||||||
|
|
||||||
|
## Store Structure
|
||||||
|
|
||||||
|
**Location**: `src/lib/stores/`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Stores
|
||||||
|
subgraph auth["auth.ts"]
|
||||||
|
AuthState["AuthState<br/>- user<br/>- serverUrl<br/>- token<br/>- isLoading"]
|
||||||
|
end
|
||||||
|
subgraph playerStore["player.ts"]
|
||||||
|
PlayerStoreState["PlayerState<br/>- kind<br/>- media<br/>- position<br/>- duration"]
|
||||||
|
end
|
||||||
|
subgraph queueStore["queue.ts"]
|
||||||
|
QueueState["QueueState<br/>- items<br/>- index<br/>- shuffle<br/>- repeat"]
|
||||||
|
end
|
||||||
|
subgraph libraryStore["library.ts"]
|
||||||
|
LibraryState["LibraryState<br/>- libraries<br/>- items<br/>- loading"]
|
||||||
|
end
|
||||||
|
subgraph Derived["Derived Stores"]
|
||||||
|
DerivedList["isAuthenticated, currentUser<br/>isPlaying, isPaused, currentMedia<br/>hasNext, hasPrevious, isShuffle<br/>libraryItems, isLibraryLoading"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Music Library Architecture
|
||||||
|
|
||||||
|
**Category-Based Navigation:**
|
||||||
|
|
||||||
|
JellyTau's music library uses a category-based navigation system with a dedicated landing page that routes users to specialized views for different content types.
|
||||||
|
|
||||||
|
**Route Structure:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Music["/library/music<br/>(Landing page with category cards)"]
|
||||||
|
Tracks["Tracks<br/>(List view only)"]
|
||||||
|
Artists["Artists<br/>(Grid view)"]
|
||||||
|
Albums["Albums<br/>(Grid view)"]
|
||||||
|
Playlists["Playlists<br/>(Grid view)"]
|
||||||
|
Genres["Genres<br/>(Genre browser)"]
|
||||||
|
|
||||||
|
Music --> Tracks
|
||||||
|
Music --> Artists
|
||||||
|
Music --> Albums
|
||||||
|
Music --> Playlists
|
||||||
|
Music --> Genres
|
||||||
|
```
|
||||||
|
|
||||||
|
**View Enforcement:**
|
||||||
|
|
||||||
|
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||||
|
|--------------|-----------|----------------|----------------|
|
||||||
|
| Tracks | List (forced) | No | `TrackList` |
|
||||||
|
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||||
|
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||||
|
|
||||||
|
**TrackList Component:**
|
||||||
|
|
||||||
|
The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a dedicated component for displaying songs in list format:
|
||||||
|
|
||||||
|
- **No Thumbnails**: Track numbers only (transform to play button on hover)
|
||||||
|
- **Desktop Layout**: Table with columns: #, Title, Artist, Album, Duration
|
||||||
|
- **Mobile Layout**: Compact rows with track number and metadata
|
||||||
|
- **Configurable Columns**: `showArtist` and `showAlbum` props control column visibility
|
||||||
|
- **Click Behavior**: Clicking a track plays it and queues all filtered tracks
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```svelte
|
||||||
|
<TrackList
|
||||||
|
tracks={filteredTracks}
|
||||||
|
loading={loading}
|
||||||
|
showArtist={true}
|
||||||
|
showAlbum={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**LibraryGrid forceGrid Prop:**
|
||||||
|
|
||||||
|
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||||
|
|
||||||
|
## Playback Reporting Service
|
||||||
|
|
||||||
|
**Location**: `src/lib/services/playbackReporting.ts`
|
||||||
|
|
||||||
|
The playback reporting service ensures playback progress is synced to both the Jellyfin server AND the local SQLite database. This dual-write approach enables:
|
||||||
|
- Offline "Continue Watching" functionality
|
||||||
|
- Sync queue for when network is unavailable
|
||||||
|
- Consistent progress across app restarts
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant VideoPlayer
|
||||||
|
participant PlaybackService as playbackReporting.ts
|
||||||
|
participant LocalDB as Local SQLite<br/>(Tauri Commands)
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
|
||||||
|
VideoPlayer->>PlaybackService: reportPlaybackProgress(itemId, position)
|
||||||
|
|
||||||
|
par Local Storage (always works)
|
||||||
|
PlaybackService->>LocalDB: invoke("storage_update_playback_progress")
|
||||||
|
LocalDB-->>PlaybackService: Ok (pending_sync = true)
|
||||||
|
and Server Sync (if online)
|
||||||
|
PlaybackService->>Jellyfin: POST /Sessions/Playing/Progress
|
||||||
|
Jellyfin-->>PlaybackService: Ok
|
||||||
|
PlaybackService->>LocalDB: invoke("storage_mark_synced")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service Functions:**
|
||||||
|
- `reportPlaybackStart(itemId, positionSeconds)` - Called when playback begins
|
||||||
|
- `reportPlaybackProgress(itemId, positionSeconds, isPaused)` - Called periodically (every 10s)
|
||||||
|
- `reportPlaybackStopped(itemId, positionSeconds)` - Called when player closes or video ends
|
||||||
|
|
||||||
|
**Tauri Commands:**
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `storage_update_playback_progress` | Update position in local DB (marks `pending_sync = true`) |
|
||||||
|
| `storage_mark_played` | Mark item as played, increment play count |
|
||||||
|
| `storage_get_playback_progress` | Get stored progress for an item |
|
||||||
|
| `storage_mark_synced` | Clear `pending_sync` flag after successful server sync |
|
||||||
|
|
||||||
|
**Database Schema Notes:**
|
||||||
|
- The `user_data` table stores playback progress using Jellyfin IDs directly (as TEXT)
|
||||||
|
- Playback progress can be tracked even when the full item metadata hasn't been downloaded yet
|
||||||
|
|
||||||
|
**Resume Playback Feature:**
|
||||||
|
- When loading media for playback, the app checks local database for saved progress
|
||||||
|
- If progress exists (>30 seconds watched and <90% complete), shows resume dialog
|
||||||
|
- User can choose to "Resume" from saved position or "Start from Beginning"
|
||||||
|
- For video: Uses `startTimeSeconds` parameter in stream URL to begin transcoding from resume point
|
||||||
|
- For audio: Seeks to resume position after loading via MPV backend
|
||||||
|
- Implemented in `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
## Repository Architecture (Rust-Based)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/repository/`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class MediaRepository {
|
||||||
|
<<trait>>
|
||||||
|
+get_libraries()
|
||||||
|
+get_items(parent_id, options)
|
||||||
|
+get_item(item_id)
|
||||||
|
+search(query, options)
|
||||||
|
+get_latest_items(parent_id, limit)
|
||||||
|
+get_resume_items(parent_id, limit)
|
||||||
|
+get_next_up_episodes(series_id, limit)
|
||||||
|
+get_genres(parent_id)
|
||||||
|
+get_playback_info(item_id)
|
||||||
|
+report_playback_start(item_id, position_ticks)
|
||||||
|
+report_playback_progress(item_id, position_ticks, is_paused)
|
||||||
|
+report_playback_stopped(item_id, position_ticks)
|
||||||
|
+mark_favorite(item_id)
|
||||||
|
+unmark_favorite(item_id)
|
||||||
|
+get_person(person_id)
|
||||||
|
+get_items_by_person(person_id, options)
|
||||||
|
+get_image_url(item_id, image_type, options)
|
||||||
|
+create_playlist(name, item_ids)
|
||||||
|
+delete_playlist(playlist_id)
|
||||||
|
+rename_playlist(playlist_id, name)
|
||||||
|
+get_playlist_items(playlist_id)
|
||||||
|
+add_to_playlist(playlist_id, item_ids)
|
||||||
|
+remove_from_playlist(playlist_id, entry_ids)
|
||||||
|
+move_playlist_item(playlist_id, item_id, new_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
class OnlineRepository {
|
||||||
|
-http_client: Arc~HttpClient~
|
||||||
|
-server_url: String
|
||||||
|
-user_id: String
|
||||||
|
-access_token: String
|
||||||
|
-connectivity: Option~Arc~ConnectivityMonitor~~
|
||||||
|
+new()
|
||||||
|
+with_connectivity()
|
||||||
|
-report_outcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineRepository {
|
||||||
|
-db_service: Arc~DatabaseService~
|
||||||
|
-server_id: String
|
||||||
|
-user_id: String
|
||||||
|
+new()
|
||||||
|
+cache_library()
|
||||||
|
+cache_items()
|
||||||
|
+cache_item()
|
||||||
|
}
|
||||||
|
|
||||||
|
class HybridRepository {
|
||||||
|
-online: Arc~OnlineRepository~
|
||||||
|
-offline: Arc~OfflineRepository~
|
||||||
|
+new()
|
||||||
|
-parallel_race()
|
||||||
|
-cache_with_timeout()
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaRepository <|.. OnlineRepository
|
||||||
|
MediaRepository <|.. OfflineRepository
|
||||||
|
MediaRepository <|.. HybridRepository
|
||||||
|
|
||||||
|
HybridRepository --> OnlineRepository
|
||||||
|
HybridRepository --> OfflineRepository
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
|
||||||
|
1. **Cache-First Racing Strategy** (`hybrid.rs`):
|
||||||
|
- Runs cache (SQLite) and server (HTTP) queries in parallel
|
||||||
|
- Cache has 100ms timeout
|
||||||
|
- Returns cache result if it has meaningful content
|
||||||
|
- Falls back to server result otherwise
|
||||||
|
- Background cache updates planned
|
||||||
|
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
|
||||||
|
|
||||||
|
2. **Handle-Based Resource Management** (`repository.rs` commands):
|
||||||
|
```rust
|
||||||
|
// Frontend creates repository with UUID handle
|
||||||
|
repository_create(server_url, user_id, access_token, server_id) -> String (UUID)
|
||||||
|
|
||||||
|
// All operations use handle for identification
|
||||||
|
repository_get_libraries(handle: String) -> Vec<Library>
|
||||||
|
repository_get_items(handle: String, ...) -> SearchResult
|
||||||
|
|
||||||
|
// Cleanup when done
|
||||||
|
repository_destroy(handle: String)
|
||||||
|
```
|
||||||
|
- Enables multiple concurrent repository instances
|
||||||
|
- Thread-safe with `Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>`
|
||||||
|
- No global state conflicts
|
||||||
|
|
||||||
|
3. **Frontend API Layer** (`src/lib/api/repository-client.ts`):
|
||||||
|
- Thin TypeScript wrapper over Rust commands
|
||||||
|
- Maintains handle throughout session
|
||||||
|
- All methods: `invoke<T>("repository_operation", { handle, ...args })`
|
||||||
|
- ~100 lines (down from 1061 lines)
|
||||||
|
|
||||||
|
## Playback Mode System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/playback_mode/mod.rs`
|
||||||
|
|
||||||
|
The playback mode system manages transitions between local device playback and remote Jellyfin session control:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PlaybackMode {
|
||||||
|
Local, // Playing on local device
|
||||||
|
Remote { session_id: String }, // Controlling remote session
|
||||||
|
Idle, // Not playing
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlaybackModeManager {
|
||||||
|
current_mode: PlaybackMode,
|
||||||
|
player_controller: Arc<Mutex<PlayerController>>,
|
||||||
|
jellyfin_client: Arc<JellyfinClient>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Operations:**
|
||||||
|
|
||||||
|
1. **Transfer to Remote** (`transfer_to_remote(session_id)`):
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI
|
||||||
|
participant Manager as PlaybackModeManager
|
||||||
|
participant Player as PlayerController
|
||||||
|
participant Jellyfin as Jellyfin API
|
||||||
|
|
||||||
|
UI->>Manager: transfer_to_remote(session_id)
|
||||||
|
Manager->>Player: Extract queue items
|
||||||
|
Manager->>Manager: Get Jellyfin IDs from queue
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing
|
||||||
|
Note over Jellyfin: Start playback with queue
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek
|
||||||
|
Note over Jellyfin: Seek to current position
|
||||||
|
Manager->>Player: Stop local playback
|
||||||
|
Manager->>Manager: Set mode to Remote
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Transfer to Local** (`transfer_to_local(item_id, position_ticks)`):
|
||||||
|
- Stops remote session playback
|
||||||
|
- Prepares local player to resume
|
||||||
|
- Sets mode to Local
|
||||||
|
|
||||||
|
**Tauri Commands** (`playback_mode.rs`):
|
||||||
|
- `playback_mode_get_current()` -> Returns current PlaybackMode
|
||||||
|
- `playback_mode_transfer_to_remote(session_id)` -> Async transfer
|
||||||
|
- `playback_mode_transfer_to_local(item_id, position_ticks)` -> Async transfer back
|
||||||
|
- `playback_mode_is_transferring()` -> Check transfer state
|
||||||
|
- `playback_mode_set(mode)` -> Direct mode setting
|
||||||
|
|
||||||
|
**Frontend Store** (`src/lib/stores/playbackMode.ts`):
|
||||||
|
- Thin wrapper calling Rust commands
|
||||||
|
- Maintains UI state (isTransferring, transferError)
|
||||||
|
- Listens to mode change events from Rust
|
||||||
|
|
||||||
|
## Database Service Abstraction
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/storage/db_service.rs`
|
||||||
|
|
||||||
|
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DatabaseService: Send + Sync {
|
||||||
|
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
||||||
|
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
||||||
|
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
||||||
|
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
||||||
|
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
||||||
|
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
||||||
|
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
||||||
|
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RusqliteService {
|
||||||
|
connection: Arc<Mutex<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseService for RusqliteService {
|
||||||
|
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
||||||
|
let conn = self.connection.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// Execute query on blocking thread pool
|
||||||
|
}).await?
|
||||||
|
}
|
||||||
|
// ... other methods use spawn_blocking
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits:**
|
||||||
|
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
||||||
|
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
||||||
|
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
||||||
|
- **Testable**: Can mock DatabaseService for tests
|
||||||
|
|
||||||
|
**Usage Pattern:**
|
||||||
|
```rust
|
||||||
|
// Before (blocking - causes UI freeze)
|
||||||
|
let conn = database.connection();
|
||||||
|
let conn = conn.lock().unwrap(); // BLOCKS
|
||||||
|
conn.query_row(...) // BLOCKS
|
||||||
|
|
||||||
|
// After (async - no freezing)
|
||||||
|
let db_service = database.service();
|
||||||
|
let query = Query::with_params("SELECT ...", vec![...]);
|
||||||
|
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Routes["Routes (src/routes/)"]
|
||||||
|
LoginPage["Login Page"]
|
||||||
|
LibLayout["Library Layout"]
|
||||||
|
LibDetail["Album/Series Detail"]
|
||||||
|
MusicCategory["Music Category Landing"]
|
||||||
|
Tracks["Tracks"]
|
||||||
|
Artists["Artists"]
|
||||||
|
Albums["Albums"]
|
||||||
|
Playlists["Playlists"]
|
||||||
|
Genres["Genres"]
|
||||||
|
Downloads["Downloads Page"]
|
||||||
|
Settings["Settings Page"]
|
||||||
|
PlayerPage["Player Page"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PlayerComps["Player Components"]
|
||||||
|
AudioPlayer["AudioPlayer"]
|
||||||
|
VideoPlayer["VideoPlayer"]
|
||||||
|
MiniPlayer["MiniPlayer"]
|
||||||
|
Controls["Controls"]
|
||||||
|
Queue["Queue"]
|
||||||
|
SleepTimerModal["SleepTimerModal"]
|
||||||
|
SleepTimerIndicator["SleepTimerIndicator"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph SessionComps["Sessions Components"]
|
||||||
|
CastButton["CastButton"]
|
||||||
|
SessionModal["SessionPickerModal"]
|
||||||
|
SessionCard["SessionCard"]
|
||||||
|
SessionsList["SessionsList"]
|
||||||
|
RemoteControls["RemoteControls"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph LibraryComps["Library Components"]
|
||||||
|
LibGrid["LibraryGrid"]
|
||||||
|
LibListView["LibraryListView"]
|
||||||
|
TrackList["TrackList"]
|
||||||
|
PlaylistDetail["PlaylistDetailView"]
|
||||||
|
DownloadBtn["DownloadButton"]
|
||||||
|
MediaCard["MediaCard"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph PlaylistComps["Playlist Components"]
|
||||||
|
CreatePlaylistModal["CreatePlaylistModal"]
|
||||||
|
AddToPlaylistModal["AddToPlaylistModal"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CommonComps["Common Components"]
|
||||||
|
ScrollPicker["ScrollPicker"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OtherComps["Other Components"]
|
||||||
|
Search["Search"]
|
||||||
|
FavoriteBtn["FavoriteButton"]
|
||||||
|
DownloadItem["DownloadItem"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LibLayout --> PlayerComps
|
||||||
|
LibLayout --> LibDetail
|
||||||
|
MusicCategory --> Tracks
|
||||||
|
MusicCategory --> Artists
|
||||||
|
MusicCategory --> Albums
|
||||||
|
MusicCategory --> Playlists
|
||||||
|
MusicCategory --> Genres
|
||||||
|
LibDetail --> LibraryComps
|
||||||
|
Playlists --> PlaylistComps
|
||||||
|
Playlists --> PlaylistDetail
|
||||||
|
Downloads --> DownloadItem
|
||||||
|
PlayerPage --> PlayerComps
|
||||||
|
|
||||||
|
MiniPlayer --> CastButton
|
||||||
|
CastButton --> SessionModal
|
||||||
|
SleepTimerModal --> ScrollPicker
|
||||||
|
PlayerComps --> LibraryComps
|
||||||
|
```
|
||||||
|
|
||||||
|
## MiniPlayer Behavior
|
||||||
|
|
||||||
|
**Location**: `src/lib/components/player/MiniPlayer.svelte`
|
||||||
|
|
||||||
|
The MiniPlayer is a persistent bottom bar for audio playback that supports touch gestures and playback controls.
|
||||||
|
|
||||||
|
**Touch Gesture Handling:**
|
||||||
|
|
||||||
|
The MiniPlayer uses touch events to distinguish between taps (on controls) and swipe-up gestures (to expand to full player page):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleTouchStart(e: TouchEvent) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchEndX = touchStartX; // Initialize to start position
|
||||||
|
touchEndY = touchStartY; // Prevents taps being treated as swipes
|
||||||
|
isSwiping = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decision**: `touchEndX`/`touchEndY` must be initialized to the start position in `handleTouchStart`. Without this, a pure tap (no `touchmove` event fired) would compute the swipe distance against (0,0), making every tap look like a massive swipe-up and inadvertently navigating to the player page.
|
||||||
|
|
||||||
|
**Skip Button State:**
|
||||||
|
|
||||||
|
The MiniPlayer's next/previous buttons are enabled based on `appState.hasNext`/`hasPrevious`, which are updated by `playerEvents.ts` calling `invoke("player_get_queue")` on every `StateChanged` event from the backend.
|
||||||
|
|
||||||
|
## Sleep Timer Architecture
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/sleep_timer.rs`, `src-tauri/src/player/mod.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-026 | DR-029
|
||||||
|
|
||||||
|
The sleep timer supports three modes for stopping playback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||||
|
pub enum SleepTimerMode {
|
||||||
|
Off,
|
||||||
|
Time { end_time: i64 }, // Unix timestamp in milliseconds
|
||||||
|
EndOfTrack, // Stop after current track/episode
|
||||||
|
Episodes { remaining: u32 }, // Stop after N more episodes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timer Modes:**
|
||||||
|
|
||||||
|
| Mode | Trigger | How It Stops |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| Time | User selects 15/30/45/60 min via roller UI | Background timer thread stops backend when `remaining_seconds == 0`; also checked at track boundaries in `on_playback_ended()` |
|
||||||
|
| EndOfTrack | User clicks "End of current track" | Checked in `on_playback_ended()`, returns `AutoplayDecision::Stop` |
|
||||||
|
| Episodes | User selects 1-10 episodes | `decrement_episode()` in `on_playback_ended()`, stops when counter reaches 0 |
|
||||||
|
|
||||||
|
**Time-Based Timer Flow:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as SleepTimerModal
|
||||||
|
participant Store as sleepTimer store
|
||||||
|
participant Rust as PlayerController
|
||||||
|
participant Thread as Timer Thread
|
||||||
|
participant Backend as PlayerBackend
|
||||||
|
|
||||||
|
UI->>Store: setTimeTimer(30)
|
||||||
|
Store->>Rust: invoke("player_set_sleep_timer", {mode})
|
||||||
|
Rust->>Rust: Set SleepTimerMode::Time { end_time }
|
||||||
|
Rust->>UI: Emit SleepTimerChanged event
|
||||||
|
|
||||||
|
loop Every 1 second
|
||||||
|
Thread->>Thread: update_remaining_seconds()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (countdown)
|
||||||
|
alt remaining_seconds == 0
|
||||||
|
Thread->>Backend: stop()
|
||||||
|
Thread->>UI: Emit SleepTimerChanged (Off)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Components:**
|
||||||
|
|
||||||
|
- **ScrollPicker** (`src/lib/components/common/ScrollPicker.svelte`): Reusable scroll-wheel picker using CSS `scroll-snap-type: y mandatory`. Configurable items, visible count, and item height. Used by SleepTimerModal for time selection.
|
||||||
|
- **SleepTimerModal** (`src/lib/components/player/SleepTimerModal.svelte`): Modal with three sections - time picker (roller), end of track button, episode counter. Time section uses ScrollPicker with 15/30/45/60 min options. Accepts optional `mediaType` prop to override queue-based detection (used by VideoPlayer since video playback clears the audio queue).
|
||||||
|
- **SleepTimerIndicator** (`src/lib/components/player/SleepTimerIndicator.svelte`): Compact indicator showing active timer status with countdown.
|
||||||
|
- **Sleep buttons**: Clock icon buttons on AudioPlayer header, Controls bar, MiniPlayer, and VideoPlayer control bar. Shows clock icon when inactive, SleepTimerIndicator when active.
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
1. **All logic in Rust**: Frontend only displays state and invokes commands
|
||||||
|
2. **Background timer thread**: Handles time-based countdown independently of track boundaries
|
||||||
|
3. **Dual stop mechanism for Time mode**: Timer thread stops mid-track; `on_playback_ended()` catches edge case at track boundary
|
||||||
|
4. **Event-driven UI updates**: Timer thread emits `SleepTimerChanged` every second for countdown display
|
||||||
|
|
||||||
|
## Auto-Play Episode Limit
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs`
|
||||||
|
|
||||||
|
**TRACES**: UR-023 | DR-049
|
||||||
|
|
||||||
|
Limits how many episodes auto-play consecutively before requiring manual intervention.
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In AutoplaySettings (runtime, in PlayerController)
|
||||||
|
pub struct AutoplaySettings {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub countdown_seconds: u32,
|
||||||
|
pub max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
|
||||||
|
// In VideoSettings (persisted, settings page)
|
||||||
|
pub struct VideoSettings {
|
||||||
|
pub auto_play_next_episode: bool,
|
||||||
|
pub auto_play_countdown_seconds: u32,
|
||||||
|
pub auto_play_max_episodes: u32, // 0 = unlimited
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session-Based Counter:**
|
||||||
|
|
||||||
|
The `autoplay_episode_count` field in `PlayerController` tracks consecutive auto-played episodes:
|
||||||
|
|
||||||
|
- **Incremented**: In `on_playback_ended()` when auto-playing next episode
|
||||||
|
- **Reset**: On any manual user action (`play_item()`, `play_queue()`, `next()`, `previous()`)
|
||||||
|
- **Limit check**: When `max_episodes > 0` and `count >= max_episodes`, the popup shows with `auto_advance: false` - user must manually click "Play Now" to continue
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
PlaybackEnded["on_playback_ended()"] --> CheckEpisode{"Is video<br/>episode?"}
|
||||||
|
CheckEpisode -->|"No"| AudioFlow["Audio queue logic"]
|
||||||
|
CheckEpisode -->|"Yes"| FetchNext["Fetch next episode"]
|
||||||
|
FetchNext --> IncrementCount["increment_autoplay_count()"]
|
||||||
|
IncrementCount --> CheckLimit{"max_episodes > 0<br/>AND count >= max?"}
|
||||||
|
CheckLimit -->|"No"| ShowPopup["ShowNextEpisodePopup<br/>auto_advance: true"]
|
||||||
|
CheckLimit -->|"Yes"| ShowPopupManual["ShowNextEpisodePopup<br/>auto_advance: false"]
|
||||||
|
ShowPopupManual --> UserClick["User clicks 'Play Now'"]
|
||||||
|
UserClick --> PlayItem["play_item() -> resets counter"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Settings Sync:**
|
||||||
|
|
||||||
|
`VideoSettings` (settings page) and `AutoplaySettings` (PlayerController runtime) are synced via `player_set_video_settings`, which updates both the `VideoSettingsWrapper` state and calls `controller.set_autoplay_settings()`.
|
||||||
|
|
||||||
|
**Database**: Migration 016 adds `autoplay_max_episodes INTEGER DEFAULT 0` to `user_player_settings`.
|
||||||
|
|
||||||
|
**Settings UI**: Button grid with options: Unlimited, 1, 2, 3, 5, 10 episodes. Visible only when auto-play is enabled.
|
||||||
|
|
||||||
|
## Player Page Navigation Guard
|
||||||
|
|
||||||
|
**Location**: `src/routes/player/[id]/+page.svelte`
|
||||||
|
|
||||||
|
When the user navigates to the full player page (e.g., by swiping up on MiniPlayer), the `loadAndPlay` function checks whether the track is already playing before initiating new playback:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const alreadyPlayingMedia = get(storeCurrentMedia);
|
||||||
|
if (alreadyPlayingMedia?.id === id && !startPosition) {
|
||||||
|
// Track already playing - show UI without restarting playback
|
||||||
|
// Fetch queue status for hasNext/hasPrevious
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Matters**: Without this guard, navigating to the player page would restart playback with a single-track queue, destroying the existing album/playlist queue that the backend is playing. The Rust backend maintains the full queue (visible on the Android lock screen), but the frontend `loadAndPlay` function would overwrite it by calling `player_play_tracks` with just the current track.
|
||||||
|
|
||||||
|
## Playlist Management UI
|
||||||
|
|
||||||
|
**TRACES**: UR-014 | JA-019 | JA-020
|
||||||
|
|
||||||
|
**Location**: `src/lib/components/playlist/`, `src/lib/components/library/PlaylistDetailView.svelte`
|
||||||
|
|
||||||
|
The playlist UI provides full CRUD operations for Jellyfin playlists with offline sync support.
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
|
||||||
|
- **CreatePlaylistModal** (`src/lib/components/playlist/CreatePlaylistModal.svelte`):
|
||||||
|
- Modal for creating new playlists with a name input
|
||||||
|
- Accepts optional `initialItemIds` to pre-populate with tracks
|
||||||
|
- Keyboard support: Enter to create, Escape to close
|
||||||
|
- Navigates to new playlist detail page on creation
|
||||||
|
|
||||||
|
- **AddToPlaylistModal** (`src/lib/components/playlist/AddToPlaylistModal.svelte`):
|
||||||
|
- Modal listing all existing playlists to add tracks to
|
||||||
|
- "New Playlist" button for inline creation flow
|
||||||
|
- Shows playlist artwork via CachedImage
|
||||||
|
- Loading state with skeleton placeholders
|
||||||
|
|
||||||
|
- **PlaylistDetailView** (`src/lib/components/library/PlaylistDetailView.svelte`):
|
||||||
|
- Full playlist detail page with artwork, name, track count, total duration
|
||||||
|
- Click-to-rename with inline editing
|
||||||
|
- Play all / shuffle play buttons
|
||||||
|
- Delete with confirmation dialog
|
||||||
|
- Per-track removal buttons
|
||||||
|
- Uses `TrackList` component for track display
|
||||||
|
- Passes `{ type: "playlist", playlistId, playlistName }` context to player
|
||||||
|
|
||||||
|
- **Playlists Page** (`src/routes/library/music/playlists/+page.svelte`):
|
||||||
|
- Grid view using `GenericMediaListPage`
|
||||||
|
- Floating action button (FAB) to create new playlists
|
||||||
|
- Search by playlist name
|
||||||
|
|
||||||
|
**Frontend API Methods** (`src/lib/api/repository-client.ts`):
|
||||||
|
- `createPlaylist(name, itemIds?)` -> `PlaylistCreatedResult`
|
||||||
|
- `deletePlaylist(playlistId)`
|
||||||
|
- `renamePlaylist(playlistId, name)`
|
||||||
|
- `getPlaylistItems(playlistId)` -> `PlaylistEntry[]`
|
||||||
|
- `addToPlaylist(playlistId, itemIds)`
|
||||||
|
- `removeFromPlaylist(playlistId, entryIds)`
|
||||||
|
- `movePlaylistItem(playlistId, itemId, newIndex)`
|
||||||
|
|
||||||
|
**Offline Sync** (`src/lib/services/syncService.ts`):
|
||||||
|
All playlist mutations are queued for offline sync:
|
||||||
|
- `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename`
|
||||||
|
- `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem`
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Data Flow
|
||||||
|
|
||||||
|
## Repository Query Flow (Cache-First)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as Svelte Component
|
||||||
|
participant Client as RepositoryClient (TS)
|
||||||
|
participant Rust as Tauri Command
|
||||||
|
participant Hybrid as HybridRepository
|
||||||
|
participant Cache as OfflineRepository (SQLite)
|
||||||
|
participant Server as OnlineRepository (HTTP)
|
||||||
|
participant Conn as ConnectivityMonitor
|
||||||
|
|
||||||
|
UI->>Client: getItems(parentId)
|
||||||
|
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
||||||
|
Rust->>Hybrid: get_items()
|
||||||
|
|
||||||
|
par Parallel Racing
|
||||||
|
Hybrid->>Cache: get_items() with 100ms timeout
|
||||||
|
Hybrid->>Server: get_items() (no timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over Server,Conn: Every server request reports its outcome
|
||||||
|
alt Server succeeds (or answers with 4xx/5xx)
|
||||||
|
Server->>Conn: mark_reachable() (server is up)
|
||||||
|
else Network failure / timeout
|
||||||
|
Server->>Conn: mark_unreachable() (debounced)
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Cache returns with content
|
||||||
|
Cache-->>Hybrid: Result with items
|
||||||
|
Hybrid-->>Rust: Return cache result
|
||||||
|
else Cache timeout or empty
|
||||||
|
Server-->>Hybrid: Fresh result
|
||||||
|
Hybrid-->>Rust: Return server result
|
||||||
|
end
|
||||||
|
|
||||||
|
Rust-->>Client: SearchResult
|
||||||
|
Client-->>UI: items[]
|
||||||
|
Note over UI: Reactive update
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- Cache queries have 100ms timeout for responsiveness
|
||||||
|
- Server queries always run for fresh data
|
||||||
|
- Cache wins if it has meaningful content
|
||||||
|
- Automatic fallback to server if cache is empty/stale
|
||||||
|
- Background cache updates (planned)
|
||||||
|
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
||||||
|
|
||||||
|
## Playback Initiation Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant AudioPlayer
|
||||||
|
participant Tauri as Tauri IPC
|
||||||
|
participant Command as player_play_item()
|
||||||
|
participant Controller as PlayerController
|
||||||
|
participant Backend as PlayerBackend
|
||||||
|
participant Store as Frontend Store
|
||||||
|
|
||||||
|
User->>AudioPlayer: clicks play
|
||||||
|
AudioPlayer->>Tauri: invoke("player_play_item", {item})
|
||||||
|
Tauri->>Command: player_play_item()
|
||||||
|
Command->>Command: Convert PlayItemRequest -> MediaItem
|
||||||
|
Command->>Controller: play_item(item)
|
||||||
|
Controller->>Backend: load(item)
|
||||||
|
Note over Backend: State -> Loading
|
||||||
|
Controller->>Backend: play()
|
||||||
|
Note over Backend: State -> Playing
|
||||||
|
Controller-->>Command: Ok(())
|
||||||
|
Command-->>Tauri: PlayerStatus {state, position, duration, volume}
|
||||||
|
Tauri-->>AudioPlayer: status
|
||||||
|
AudioPlayer->>Store: player.setPlaying(media, position, duration)
|
||||||
|
Note over Store: UI updates reactively
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playback Mode Transfer Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI as Cast Button
|
||||||
|
participant Store as playbackMode store
|
||||||
|
participant Rust as Tauri Command
|
||||||
|
participant Manager as PlaybackModeManager
|
||||||
|
participant Player as PlayerController
|
||||||
|
participant Jellyfin as Jellyfin API
|
||||||
|
|
||||||
|
UI->>Store: transferToRemote(sessionId)
|
||||||
|
Store->>Rust: invoke("playback_mode_transfer_to_remote", {sessionId})
|
||||||
|
Rust->>Manager: transfer_to_remote()
|
||||||
|
|
||||||
|
Manager->>Player: Get current queue
|
||||||
|
Player-->>Manager: Vec<MediaItem>
|
||||||
|
Manager->>Manager: Extract Jellyfin IDs
|
||||||
|
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing<br/>{itemIds, startIndex}
|
||||||
|
Jellyfin-->>Manager: 200 OK
|
||||||
|
|
||||||
|
Manager->>Jellyfin: POST /Sessions/{id}/Playing/Seek<br/>{positionTicks}
|
||||||
|
Jellyfin-->>Manager: 200 OK
|
||||||
|
|
||||||
|
Manager->>Player: stop()
|
||||||
|
Manager->>Manager: mode = Remote {sessionId}
|
||||||
|
|
||||||
|
Manager-->>Rust: Ok(())
|
||||||
|
Rust-->>Store: PlaybackMode
|
||||||
|
Store->>UI: Update cast icon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Queue Navigation Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User["User clicks Next"] --> Invoke["invoke('player_next')"]
|
||||||
|
Invoke --> ControllerNext["controller.next()"]
|
||||||
|
ControllerNext --> QueueNext["queue.next()<br/>- Check repeat mode<br/>- Check shuffle<br/>- Update history"]
|
||||||
|
|
||||||
|
QueueNext --> None["None<br/>(at end)"]
|
||||||
|
QueueNext --> Some["Some(next)"]
|
||||||
|
QueueNext --> Same["Same<br/>(repeat one)"]
|
||||||
|
|
||||||
|
Some --> PlayItem["play_item(next)<br/>Returns new status"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Volume Control Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Slider as Volume Slider
|
||||||
|
participant Handler as handleVolumeChange()
|
||||||
|
participant Tauri as Tauri IPC
|
||||||
|
participant Command as player_set_volume
|
||||||
|
participant Controller as PlayerController
|
||||||
|
participant Backend as MpvBackend/NullBackend
|
||||||
|
participant Events as playerEvents.ts
|
||||||
|
participant Store as Player Store
|
||||||
|
participant UI
|
||||||
|
|
||||||
|
User->>Slider: adjusts (0-100)
|
||||||
|
Slider->>Handler: oninput event
|
||||||
|
Handler->>Handler: Convert 0-100 -> 0.0-1.0
|
||||||
|
Handler->>Tauri: invoke("player_set_volume", {volume})
|
||||||
|
Tauri->>Command: player_set_volume
|
||||||
|
Command->>Controller: set_volume(volume)
|
||||||
|
Controller->>Backend: set_volume(volume)
|
||||||
|
Backend->>Backend: Clamp to 0.0-1.0
|
||||||
|
Note over Backend: MpvBackend: Send to MPV loop
|
||||||
|
Backend-->>Tauri: emit "player-event"
|
||||||
|
Tauri-->>Events: VolumeChanged event
|
||||||
|
Events->>Store: player.setVolume(volume)
|
||||||
|
Store-->>UI: Reactive update
|
||||||
|
Note over UI: Both AudioPlayer and<br/>MiniPlayer stay in sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
- Volume is stored in the backend (NullBackend/MpvBackend)
|
||||||
|
- `PlayerController.volume()` delegates to backend
|
||||||
|
- `get_player_status()` returns `controller.volume()` (not hardcoded)
|
||||||
|
- Frontend uses normalized 0.0-1.0 scale, UI shows 0-100
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Type Synchronization & Thread Safety
|
||||||
|
|
||||||
|
## PlayerState (Rust <-> TypeScript)
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
```rust
|
||||||
|
pub enum PlayerState {
|
||||||
|
Idle,
|
||||||
|
Loading { media: MediaItem },
|
||||||
|
Playing { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Paused { media: MediaItem, position: f64, duration: f64 },
|
||||||
|
Seeking { media: MediaItem, target: f64 },
|
||||||
|
Error { media: Option<MediaItem>, error: String },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TypeScript:**
|
||||||
|
```typescript
|
||||||
|
type PlayerState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "loading"; media: MediaItem }
|
||||||
|
| { kind: "playing"; media: MediaItem; position: number; duration: number }
|
||||||
|
| { kind: "paused"; media: MediaItem; position: number; duration: number }
|
||||||
|
| { kind: "seeking"; media: MediaItem; target: number }
|
||||||
|
| { kind: "error"; media: MediaItem | null; error: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
## MediaItem Serialization
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Rust (serde serialization)
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct MediaItem {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artist: Option<String>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TypeScript
|
||||||
|
interface MediaItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist?: string;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tauri v2 IPC Parameter Naming Convention
|
||||||
|
|
||||||
|
**CRITICAL**: Tauri v2's `#[tauri::command]` macro automatically converts snake_case Rust parameter names to camelCase for the frontend. All `invoke()` calls must use camelCase for top-level parameters.
|
||||||
|
|
||||||
|
**Rule**: Rust `fn cmd(repository_handle: String)` -> Frontend sends `{ repositoryHandle: "..." }`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// CORRECT - Tauri v2 auto-converts snake_case -> camelCase
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repositoryHandle: "handle-123", // Rust: repository_handle
|
||||||
|
request: { trackIds: ["id1"], startIndex: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("remote_send_command", {
|
||||||
|
sessionId: "session-123", // Rust: session_id
|
||||||
|
command: "PlayPause"
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke("pin_item", {
|
||||||
|
itemId: "item-123" // Rust: item_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// WRONG - snake_case causes "invalid args request" error on Android
|
||||||
|
await invoke("player_play_tracks", {
|
||||||
|
repository_handle: "handle-123", // Will fail!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameter Name Mapping (Rust -> Frontend)**:
|
||||||
|
|
||||||
|
| Rust Parameter | Frontend Parameter | Used By |
|
||||||
|
|----------------|-------------------|----|
|
||||||
|
| `repository_handle` | `repositoryHandle` | `player_play_tracks`, `player_add_track_by_id`, `player_play_album_track` |
|
||||||
|
| `session_id` | `sessionId` | `remote_send_command`, `remote_play_on_session`, `remote_session_seek` |
|
||||||
|
| `item_id` | `itemId` | `pin_item`, `unpin_item` |
|
||||||
|
| `current_item_id` | `currentItemId` | `playback_mode_transfer_to_local` |
|
||||||
|
| `position_ticks` | `positionTicks` | `playback_mode_transfer_to_local`, `remote_session_seek` |
|
||||||
|
| `item_ids` | `itemIds` | `remote_play_on_session` |
|
||||||
|
| `start_index` | `startIndex` | `remote_play_on_session` |
|
||||||
|
|
||||||
|
**Nested struct fields** use `#[serde(rename_all = "camelCase")]` separately - this is serde deserialization, not the command macro. Both layers convert independently.
|
||||||
|
|
||||||
|
**Test Coverage**: Integration tests in `src/lib/utils/tauriIntegration.test.ts` validate all invoke calls use correct camelCase parameter names.
|
||||||
|
|
||||||
|
## Rust Backend Thread Safety
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Shared state wrapped in Arc<Mutex<>>
|
||||||
|
pub struct PlayerController {
|
||||||
|
backend: Arc<Mutex<Box<dyn PlayerBackend>>>,
|
||||||
|
queue: Arc<Mutex<QueueManager>>,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tauri state wrapper
|
||||||
|
pub struct PlayerStateWrapper(pub Mutex<PlayerController>);
|
||||||
|
|
||||||
|
// Command handler pattern
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn player_play(state: State<PlayerStateWrapper>) -> Result<PlayerStatus, String> {
|
||||||
|
let mut controller = state.0.lock().unwrap(); // Acquire lock
|
||||||
|
controller.play()?; // Operate
|
||||||
|
Ok(get_player_status(&controller)) // Lock released
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Stores
|
||||||
|
|
||||||
|
Svelte stores are inherently reactive and thread-safe for UI updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { subscribe, update } = writable<PlayerStore>(initialState);
|
||||||
|
|
||||||
|
// Atomic updates
|
||||||
|
function setPlaying(media: MediaItem, position: number, duration: number) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
state: { kind: "playing", media, position, duration }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Platform-Specific Player Backends
|
||||||
|
|
||||||
|
## Player Events System
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/events.rs`
|
||||||
|
|
||||||
|
The player uses a push-based event system to notify the frontend of state changes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum PlayerStatusEvent {
|
||||||
|
/// Playback position updated (emitted periodically during playback)
|
||||||
|
PositionUpdate { position: f64, duration: f64 },
|
||||||
|
|
||||||
|
/// Player state changed
|
||||||
|
StateChanged { state: String, media_id: Option<String> },
|
||||||
|
|
||||||
|
/// Media has finished loading and is ready to play
|
||||||
|
MediaLoaded { duration: f64 },
|
||||||
|
|
||||||
|
/// Playback has ended naturally
|
||||||
|
PlaybackEnded,
|
||||||
|
|
||||||
|
/// Buffering state changed
|
||||||
|
Buffering { percent: u8 },
|
||||||
|
|
||||||
|
/// An error occurred during playback
|
||||||
|
Error { message: String, recoverable: bool },
|
||||||
|
|
||||||
|
/// Volume changed
|
||||||
|
VolumeChanged { volume: f32, muted: bool },
|
||||||
|
|
||||||
|
/// Sleep timer state changed
|
||||||
|
SleepTimerChanged {
|
||||||
|
mode: SleepTimerMode,
|
||||||
|
remaining_seconds: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show next episode popup with countdown
|
||||||
|
ShowNextEpisodePopup {
|
||||||
|
current_episode: MediaItem,
|
||||||
|
next_episode: MediaItem,
|
||||||
|
countdown_seconds: u32,
|
||||||
|
auto_advance: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Countdown tick (emitted every second during autoplay countdown)
|
||||||
|
CountdownTick { remaining_seconds: u32 },
|
||||||
|
|
||||||
|
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
||||||
|
QueueChanged {
|
||||||
|
items: Vec<MediaItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
shuffle: bool,
|
||||||
|
repeat: RepeatMode,
|
||||||
|
has_next: bool,
|
||||||
|
has_previous: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
||||||
|
SessionChanged { session: MediaSessionType },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Events are emitted via Tauri's event system:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Backend["Player Backend"]
|
||||||
|
MPV["MPV/ExoPlayer"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EventSystem["Event System"]
|
||||||
|
Emitter["TauriEventEmitter<br/>emit()"]
|
||||||
|
Bus["Tauri Event Bus<br/>'player-event'"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
Listener["playerEvents.ts<br/>Frontend Listener"]
|
||||||
|
Store["Player Store Update<br/>(position, state, etc)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MPV --> Emitter --> Bus --> Listener --> Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend Listener** (`src/lib/services/playerEvents.ts`):
|
||||||
|
- Listens for `player-event` Tauri events
|
||||||
|
- Updates player/queue stores based on event type
|
||||||
|
- Auto-advances to next track on `PlaybackEnded`
|
||||||
|
- On `StateChanged` events, calls `invoke("player_get_queue")` to update `appState.hasNext`/`hasPrevious` -- this enables MiniPlayer skip button state
|
||||||
|
|
||||||
|
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||||
|
|
||||||
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/mpv/`
|
||||||
|
|
||||||
|
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph MainThread["Main Thread"]
|
||||||
|
MpvBackend["MpvBackend<br/>- command_tx<br/>- shared_state<br/>- shutdown"]
|
||||||
|
Commands["Commands:<br/>Load, Play, Pause<br/>Stop, Seek, SetVolume"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EventLoopThread["MPV Event Loop Thread"]
|
||||||
|
EventLoop["event_loop.rs<br/>- MPV Handle<br/>- command_rx<br/>- Event Emitter"]
|
||||||
|
TauriEmitter["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MpvBackend -->|"MpvCommand"| EventLoop
|
||||||
|
MpvBackend <-->|"Arc<Mutex<>>"| EventLoop
|
||||||
|
EventLoop -->|"Events"| TauriEmitter
|
||||||
|
TauriEmitter --> FrontendStore["Frontend Store"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Components:**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Command enum sent to event loop thread
|
||||||
|
pub enum MpvCommand {
|
||||||
|
Load { url: String, media: MediaItem },
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
Seek(f64),
|
||||||
|
SetVolume(f32),
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared state between main thread and event loop
|
||||||
|
pub struct MpvSharedState {
|
||||||
|
pub state: PlayerState,
|
||||||
|
pub position: f64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub volume: f32,
|
||||||
|
pub is_loaded: bool,
|
||||||
|
pub current_media: Option<MediaItem>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Loop** (`event_loop.rs`):
|
||||||
|
- Initializes MPV with audio-only config (`vo=null`, `video=false`)
|
||||||
|
- Observes properties: `time-pos`, `duration`, `pause`, `volume`
|
||||||
|
- Emits position updates every 250ms during playback
|
||||||
|
- Processes commands from channel (non-blocking)
|
||||||
|
- Handles MPV events: `FileLoaded`, `EndFile`, `PropertyChange`
|
||||||
|
|
||||||
|
## ExoPlayerBackend (Android)
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/player/android/` and Kotlin sources
|
||||||
|
|
||||||
|
The ExoPlayer backend uses Android's Media3/ExoPlayer library via JNI.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph RustNative["Rust (Native)"]
|
||||||
|
ExoBackend["ExoPlayerBackend<br/>- player_ref<br/>- shared_state"]
|
||||||
|
NativeFuncs["JNI Callbacks<br/>nativeOnPosition...<br/>nativeOnState...<br/>nativeOnMediaLoaded<br/>nativeOnPlaybackEnd"]
|
||||||
|
TauriEmitter2["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph KotlinJVM["Kotlin (JVM)"]
|
||||||
|
JellyTauPlayer["JellyTauPlayer<br/>- ExoPlayer<br/>- Player.Listener"]
|
||||||
|
end
|
||||||
|
|
||||||
|
ExoBackend -->|"JNI Calls"| JellyTauPlayer
|
||||||
|
JellyTauPlayer -->|"Callbacks"| NativeFuncs
|
||||||
|
NativeFuncs --> TauriEmitter2
|
||||||
|
TauriEmitter2 --> FrontendStore2["Frontend Store"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kotlin Player** (`JellyTauPlayer.kt`):
|
||||||
|
```kotlin
|
||||||
|
class JellyTauPlayer(context: Context) {
|
||||||
|
private val exoPlayer: ExoPlayer
|
||||||
|
private var positionUpdateJob: Job?
|
||||||
|
|
||||||
|
// Methods callable from Rust via JNI
|
||||||
|
fun load(url: String, mediaId: String)
|
||||||
|
fun play()
|
||||||
|
fun pause()
|
||||||
|
fun stop()
|
||||||
|
fun seek(positionSeconds: Double)
|
||||||
|
fun setVolume(volume: Float)
|
||||||
|
|
||||||
|
// Native callbacks to Rust
|
||||||
|
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||||||
|
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||||||
|
private external fun nativeOnMediaLoaded(duration: Double)
|
||||||
|
private external fun nativeOnPlaybackEnded()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**JNI Callbacks** (Rust):
|
||||||
|
```rust
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate(
|
||||||
|
_env: JNIEnv, _class: JClass, position: jdouble, duration: jdouble
|
||||||
|
) {
|
||||||
|
// Update shared state
|
||||||
|
// Emit PlayerStatusEvent::PositionUpdate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android MediaSession & Remote Volume Control
|
||||||
|
|
||||||
|
**Location**: `JellyTauPlaybackService.kt`
|
||||||
|
|
||||||
|
JellyTau uses a dual MediaSession architecture for Android to support both Media3 playback controls and remote volume control:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Service["JellyTauPlaybackService"]
|
||||||
|
MediaSession["Media3 MediaSession<br/>- Lockscreen controls<br/>- Media notifications<br/>- Play/Pause/Next/Previous"]
|
||||||
|
|
||||||
|
MediaSessionCompat["MediaSessionCompat<br/>- Remote volume control<br/>- Hardware button interception"]
|
||||||
|
|
||||||
|
VolumeProvider["VolumeProviderCompat<br/>- onSetVolumeTo()<br/>- onAdjustVolume()"]
|
||||||
|
|
||||||
|
MediaSessionCompat --> VolumeProvider
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Hardware["System"]
|
||||||
|
VolumeButtons["Hardware Volume Buttons"]
|
||||||
|
Lockscreen["Lockscreen Controls"]
|
||||||
|
Notification["Media Notification"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Rust["Rust Backend"]
|
||||||
|
JNI["JNI Callbacks<br/>nativeOnRemoteVolumeChange()"]
|
||||||
|
PlaybackMode["PlaybackModeManager<br/>send_remote_volume_command()"]
|
||||||
|
JellyfinAPI["Jellyfin API<br/>session_set_volume()"]
|
||||||
|
end
|
||||||
|
|
||||||
|
VolumeButtons --> VolumeProvider
|
||||||
|
Lockscreen --> MediaSession
|
||||||
|
Notification --> MediaSession
|
||||||
|
|
||||||
|
VolumeProvider --> JNI
|
||||||
|
JNI --> PlaybackMode
|
||||||
|
PlaybackMode --> JellyfinAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
**Architecture Rationale:**
|
||||||
|
|
||||||
|
JellyTau maintains both MediaSession types because they serve different purposes:
|
||||||
|
|
||||||
|
1. **Media3 MediaSession**: Handles lockscreen/notification playback controls (play/pause/next/previous)
|
||||||
|
2. **MediaSessionCompat**: Intercepts hardware volume button presses for remote playback control
|
||||||
|
|
||||||
|
When in remote playback mode (controlling a Jellyfin session on another device):
|
||||||
|
- Volume buttons are routed through `VolumeProviderCompat`
|
||||||
|
- Volume changes are sent to the remote session via Jellyfin API
|
||||||
|
- System volume UI shows the remote session's volume level
|
||||||
|
|
||||||
|
**Remote Volume Flow:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant VolumeButton as Hardware Volume Button
|
||||||
|
participant VolumeProvider as VolumeProviderCompat
|
||||||
|
participant JNI as nativeOnRemoteVolumeChange
|
||||||
|
participant PlaybackMode as PlaybackModeManager
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
participant RemoteSession as Remote Session (TV/Browser)
|
||||||
|
|
||||||
|
User->>VolumeButton: Press Volume Up
|
||||||
|
VolumeButton->>VolumeProvider: onAdjustVolume(ADJUST_RAISE)
|
||||||
|
VolumeProvider->>VolumeProvider: remoteVolumeLevel += 2
|
||||||
|
VolumeProvider->>VolumeProvider: currentVolume = remoteVolumeLevel
|
||||||
|
VolumeProvider->>JNI: nativeOnRemoteVolumeChange("VolumeUp", level)
|
||||||
|
JNI->>PlaybackMode: send_remote_volume_command("VolumeUp", level)
|
||||||
|
PlaybackMode->>Jellyfin: POST /Sessions/{id}/Command/VolumeUp
|
||||||
|
Jellyfin->>RemoteSession: Set volume to new level
|
||||||
|
RemoteSession-->>User: Volume changes on TV/Browser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Implementation Details:**
|
||||||
|
|
||||||
|
**Enabling Remote Volume** (`enableRemoteVolume()`):
|
||||||
|
```kotlin
|
||||||
|
fun enableRemoteVolume(initialVolume: Int) {
|
||||||
|
volumeProvider = object : VolumeProviderCompat(
|
||||||
|
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE,
|
||||||
|
100, // Max volume
|
||||||
|
initialVolume
|
||||||
|
) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
when (direction) {
|
||||||
|
AudioManager.ADJUST_RAISE -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
||||||
|
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
AudioManager.ADJUST_LOWER -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
||||||
|
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSessionCompat.setPlaybackToRemote(volumeProvider)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disabling Remote Volume** (`disableRemoteVolume()`):
|
||||||
|
```kotlin
|
||||||
|
fun disableRemoteVolume() {
|
||||||
|
mediaSessionCompat.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||||||
|
volumeProvider = null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rust Integration** (`src-tauri/src/player/android/mod.rs`):
|
||||||
|
```rust
|
||||||
|
/// Enable remote volume control on Android
|
||||||
|
pub fn enable_remote_volume(initial_volume: i32) -> Result<(), String> {
|
||||||
|
start_playback_service()?;
|
||||||
|
let service_instance = get_playback_service_instance()?;
|
||||||
|
env.call_method(&service_instance, "enableRemoteVolume", "(I)V",
|
||||||
|
&[JValue::Int(initial_volume)])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dependencies** (`src-tauri/android/build.gradle.kts`):
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.media3:media3-session:1.5.1") // Media3 MediaSession
|
||||||
|
implementation("androidx.media:media:1.7.0") // MediaSessionCompat
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration with Playback Mode:**
|
||||||
|
|
||||||
|
Remote volume is automatically enabled/disabled during playback mode transfers:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In PlaybackModeManager::transfer_to_remote()
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::enable_remote_volume(50) {
|
||||||
|
log::warn!("Failed to enable remote volume: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In PlaybackModeManager::transfer_to_local()
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::disable_remote_volume() {
|
||||||
|
log::warn!("Failed to disable remote volume: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android Album Art Caching
|
||||||
|
|
||||||
|
**Location**: `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/AlbumArtCache.kt`
|
||||||
|
|
||||||
|
Album art caching provides efficient bitmap storage for lock screen notifications with automatic LRU eviction and memory management.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph JellyTauPlayer["JellyTauPlayer.kt"]
|
||||||
|
LoadMedia["loadWithMetadata()<br/>- Store artworkUrl<br/>- Launch async download"]
|
||||||
|
AsyncDownload["Coroutine<br/>- Non-blocking<br/>- Dispatchers.IO"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cache["AlbumArtCache.kt"]
|
||||||
|
MemoryCache["LruCache<String, Bitmap><br/>- 1/8 of heap<br/>- ~12-16MB typical<br/>- 50-100 albums capacity"]
|
||||||
|
Download["Download & Scale<br/>- 512x512 max<br/>- Exponential backoff"]
|
||||||
|
ErrorHandle["Error Handling<br/>- Graceful fallback<br/>- Auto-retry"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Service["JellyTauPlaybackService.kt"]
|
||||||
|
UpdateMeta["updateMediaMetadata()<br/>- Accept Bitmap parameter<br/>- Add METADATA_KEY_ALBUM_ART"]
|
||||||
|
Notification["Notification<br/>- setLargeIcon()<br/>- Lock screen display"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LoadMedia --> AsyncDownload
|
||||||
|
AsyncDownload --> MemoryCache
|
||||||
|
MemoryCache --> Download
|
||||||
|
Download --> ErrorHandle
|
||||||
|
AsyncDownload --> UpdateMeta
|
||||||
|
UpdateMeta --> Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
**AlbumArtCache Singleton:**
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class AlbumArtCache(context: Context) {
|
||||||
|
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||||
|
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||||
|
return bitmap.byteCount / 1024 // Size in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getArtwork(url: String): Bitmap? {
|
||||||
|
memoryCache.get(url)?.let { return it }
|
||||||
|
return downloadAndCache(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadAndCache(url: String): Bitmap? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// HTTP download with 5s timeout
|
||||||
|
// Scale to 512x512 max
|
||||||
|
// Auto-evict LRU if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration Flow:**
|
||||||
|
|
||||||
|
1. **Track Load** (`loadWithMetadata()`):
|
||||||
|
- Store artwork URL in `currentArtworkUrl`
|
||||||
|
- Reset bitmap to null
|
||||||
|
- Start playback immediately (non-blocking)
|
||||||
|
|
||||||
|
2. **Async Download** (Background Coroutine):
|
||||||
|
- Check cache: instant hit if available
|
||||||
|
- Network miss: download, scale, cache
|
||||||
|
- Auto-retry on network failure with exponential backoff
|
||||||
|
- Graceful fallback if artwork unavailable
|
||||||
|
|
||||||
|
3. **Notification Update**:
|
||||||
|
- Pass bitmap to `updatePlaybackServiceNotification()`
|
||||||
|
- Add to `MediaMetadataCompat` with `METADATA_KEY_ALBUM_ART`
|
||||||
|
- Display as large icon in notification
|
||||||
|
- Show on lock screen
|
||||||
|
|
||||||
|
**Memory Management:**
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Cache Size | 1/8 of heap (12-16MB typical) |
|
||||||
|
| Max Resolution | 512x512 pixels |
|
||||||
|
| Capacity | ~50-100 album arts |
|
||||||
|
| Eviction Policy | LRU (Least Recently Used) |
|
||||||
|
| Lifetime | In-memory only (app session) |
|
||||||
|
| Network Timeout | 5 seconds per download |
|
||||||
|
|
||||||
|
**Performance Characteristics:**
|
||||||
|
|
||||||
|
- **Cache Hit**: ~1ms (in-memory retrieval)
|
||||||
|
- **Cache Miss**: ~200-500ms (download + scale)
|
||||||
|
- **Playback Impact**: Zero (async downloads)
|
||||||
|
- **Memory Overhead**: Max 16MB (auto-eviction)
|
||||||
|
- **Error Recovery**: Automatic with exponential backoff
|
||||||
|
|
||||||
|
## Backend Initialization
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
Backend selection is platform-specific:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn create_player_backend(app_handle: tauri::AppHandle) -> Box<dyn PlayerBackend> {
|
||||||
|
let event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
match MpvBackend::new(event_emitter.clone()) {
|
||||||
|
Ok(backend) => return Box::new(backend),
|
||||||
|
Err(e) => eprintln!("MPV init failed: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
// ExoPlayer requires Activity context, initialized separately
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
Box::new(NullBackend::new())
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Download Manager & Offline Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/`
|
||||||
|
|
||||||
|
The download manager provides offline media support with priority-based queue management, progress tracking, retry logic, and smart caching.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
DownloadButton["DownloadButton.svelte"]
|
||||||
|
DownloadsPage["/downloads"]
|
||||||
|
DownloadsStore["downloads.ts store"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend["Rust Backend"]
|
||||||
|
Commands["Download Commands"]
|
||||||
|
DownloadManager["DownloadManager"]
|
||||||
|
DownloadWorker["DownloadWorker"]
|
||||||
|
SmartCache["SmartCache Engine"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage["Storage"]
|
||||||
|
SQLite[("SQLite DB")]
|
||||||
|
MediaFiles[("Downloaded Files")]
|
||||||
|
end
|
||||||
|
|
||||||
|
DownloadButton -->|"invoke('download_item')"| Commands
|
||||||
|
DownloadsPage -->|"invoke('get_downloads')"| Commands
|
||||||
|
Commands --> DownloadManager
|
||||||
|
DownloadManager --> DownloadWorker
|
||||||
|
DownloadManager --> SmartCache
|
||||||
|
DownloadWorker -->|"HTTP Stream"| MediaFiles
|
||||||
|
DownloadWorker -->|"Events"| DownloadsStore
|
||||||
|
Commands <--> SQLite
|
||||||
|
SmartCache <--> SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Worker
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/worker.rs`
|
||||||
|
|
||||||
|
The download worker handles HTTP streaming with retry logic and resume support:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct DownloadWorker {
|
||||||
|
client: reqwest::Client,
|
||||||
|
max_retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DownloadTask {
|
||||||
|
pub id: i64,
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub priority: i32,
|
||||||
|
pub url: String,
|
||||||
|
pub target_path: PathBuf,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub expected_size: Option<i64>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry Strategy**:
|
||||||
|
- Exponential backoff: 5s, 15s, 45s
|
||||||
|
- Maximum 3 retry attempts
|
||||||
|
- HTTP Range requests for resume support
|
||||||
|
- Progress events emitted every 1MB
|
||||||
|
|
||||||
|
**Download Flow**:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant UI
|
||||||
|
participant Command as download_item
|
||||||
|
participant DB as SQLite
|
||||||
|
participant Worker as DownloadWorker
|
||||||
|
participant Jellyfin as Jellyfin Server
|
||||||
|
participant Store as downloads store
|
||||||
|
|
||||||
|
UI->>Command: download_item(itemId, userId)
|
||||||
|
Command->>DB: INSERT INTO downloads
|
||||||
|
Command->>Worker: Start download task
|
||||||
|
Worker->>Jellyfin: GET /Items/{id}/Download
|
||||||
|
|
||||||
|
loop Progress Updates
|
||||||
|
Jellyfin->>Worker: Stream chunks
|
||||||
|
Worker->>Worker: Write to .part file
|
||||||
|
Worker->>Store: Emit progress event
|
||||||
|
Store->>UI: Update progress bar
|
||||||
|
end
|
||||||
|
|
||||||
|
Worker->>Worker: Rename .part to final
|
||||||
|
Worker->>DB: UPDATE status='completed'
|
||||||
|
Worker->>Store: Emit completed event
|
||||||
|
Store->>UI: Show completed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smart Caching Engine
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/download/cache.rs`
|
||||||
|
|
||||||
|
The smart caching system provides predictive downloads based on listening patterns:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SmartCache {
|
||||||
|
config: Arc<Mutex<CacheConfig>>,
|
||||||
|
album_play_history: Arc<Mutex<HashMap<String, Vec<String>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheConfig {
|
||||||
|
pub queue_precache_enabled: bool,
|
||||||
|
pub queue_precache_count: usize, // Default: 5
|
||||||
|
pub album_affinity_enabled: bool,
|
||||||
|
pub album_affinity_threshold: usize, // Default: 3
|
||||||
|
pub storage_limit: u64, // Default: 10GB
|
||||||
|
pub wifi_only: bool, // Default: true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caching Strategies**:
|
||||||
|
|
||||||
|
1. **Queue Pre-caching**: Auto-download next 5 tracks when playing (WiFi only)
|
||||||
|
2. **Album Affinity**: If user plays 3+ tracks from album, cache entire album
|
||||||
|
3. **LRU Eviction**: Remove least recently accessed when storage limit reached
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Play["Track Played"] --> CheckQueue{"Queue<br/>Pre-cache?"}
|
||||||
|
CheckQueue -->|"Yes"| CacheNext5["Download<br/>Next 5 Tracks"]
|
||||||
|
|
||||||
|
Play --> TrackHistory["Track Play History"]
|
||||||
|
TrackHistory --> CheckAlbum{"3+ Tracks<br/>from Album?"}
|
||||||
|
CheckAlbum -->|"Yes"| CacheAlbum["Download<br/>Full Album"]
|
||||||
|
|
||||||
|
CacheNext5 --> CheckStorage{"Storage<br/>Limit?"}
|
||||||
|
CacheAlbum --> CheckStorage
|
||||||
|
CheckStorage -->|"Exceeded"| EvictLRU["Evict LRU Items"]
|
||||||
|
CheckStorage -->|"OK"| Download["Queue Download"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download Commands
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/download.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| `download_item` | `item_id, user_id, file_path` | Queue single item download |
|
||||||
|
| `download_album` | `album_id, user_id` | Queue all tracks in album |
|
||||||
|
| `get_downloads` | `user_id, status_filter` | Get download list |
|
||||||
|
| `pause_download` | `download_id` | Pause active download |
|
||||||
|
| `resume_download` | `download_id` | Resume paused download |
|
||||||
|
| `cancel_download` | `download_id` | Cancel and delete partial |
|
||||||
|
| `delete_download` | `download_id` | Delete completed download |
|
||||||
|
|
||||||
|
## Offline Commands
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/offline.rs`
|
||||||
|
|
||||||
|
| Command | Parameters | Description |
|
||||||
|
|---------|------------|-------------|
|
||||||
|
| `offline_is_available` | `item_id` | Check if item downloaded |
|
||||||
|
| `offline_get_items` | `user_id` | Get all offline items |
|
||||||
|
| `offline_search` | `user_id, query` | Search downloaded items |
|
||||||
|
|
||||||
|
## Player Integration
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/commands/player.rs` (modified)
|
||||||
|
|
||||||
|
The player checks for local downloads before streaming:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn create_media_item(req: PlayItemRequest, db: Option<&DatabaseWrapper>) -> MediaItem {
|
||||||
|
let local_path = db.and_then(|db_wrapper| {
|
||||||
|
check_for_local_download(db_wrapper, &jellyfin_id).ok().flatten()
|
||||||
|
});
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(jellyfin_id.clone())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url: req.stream_url,
|
||||||
|
jellyfin_item_id: jellyfin_id.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
MediaItem { source, /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Downloads Store
|
||||||
|
|
||||||
|
**Location**: `src/lib/stores/downloads.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadsState {
|
||||||
|
downloads: Record<number, DownloadInfo>;
|
||||||
|
activeCount: number;
|
||||||
|
queuedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloads = createDownloadsStore();
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
downloads.downloadItem(itemId, userId, filePath)
|
||||||
|
downloads.downloadAlbum(albumId, userId)
|
||||||
|
downloads.pause(downloadId)
|
||||||
|
downloads.resume(downloadId)
|
||||||
|
downloads.cancel(downloadId)
|
||||||
|
downloads.delete(downloadId)
|
||||||
|
downloads.refresh(userId, statusFilter)
|
||||||
|
|
||||||
|
// Derived stores
|
||||||
|
export const activeDownloads = derived(downloads, ($d) =>
|
||||||
|
Object.values($d.downloads).filter((d) => d.status === 'downloading')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event Handling**:
|
||||||
|
|
||||||
|
The store listens to Tauri events for real-time updates:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
listen<DownloadEvent>('download-event', (event) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
|
||||||
|
switch (payload.type) {
|
||||||
|
case 'started':
|
||||||
|
// Update status to 'downloading'
|
||||||
|
case 'progress':
|
||||||
|
// Update progress and bytes_downloaded
|
||||||
|
case 'completed':
|
||||||
|
// Update status to 'completed', progress to 1.0
|
||||||
|
case 'failed':
|
||||||
|
// Update status to 'failed', store error message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download UI Components
|
||||||
|
|
||||||
|
**DownloadButton** (`src/lib/components/library/DownloadButton.svelte`):
|
||||||
|
- Multiple states: available, downloading, completed, failed, paused
|
||||||
|
- Circular progress ring during download
|
||||||
|
- Size variants: sm, md, lg
|
||||||
|
- Integrated into TrackList with `showDownload={true}` prop
|
||||||
|
|
||||||
|
**DownloadItem** (`src/lib/components/downloads/DownloadItem.svelte`):
|
||||||
|
- Individual download list item with progress bar
|
||||||
|
- Action buttons: pause, resume, cancel, delete
|
||||||
|
- Status indicators with color coding
|
||||||
|
|
||||||
|
**Downloads Page** (`src/routes/downloads/+page.svelte`):
|
||||||
|
- Active/Completed tabs
|
||||||
|
- Bulk actions: Pause All, Resume All, Clear Completed
|
||||||
|
- Empty states with helpful instructions
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
**downloads table**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
file_path TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
mime_type TEXT,
|
||||||
|
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
|
||||||
|
progress REAL DEFAULT 0.0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_queue
|
||||||
|
ON downloads(status, priority DESC, queued_at ASC)
|
||||||
|
WHERE status IN ('pending', 'downloading');
|
||||||
|
```
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Connectivity & Network Architecture
|
||||||
|
|
||||||
|
## HTTP Client with Retry Logic
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/jellyfin/http_client.rs`
|
||||||
|
|
||||||
|
The HTTP client provides automatic retry with exponential backoff for network resilience:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct HttpClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: HttpConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HttpConfig {
|
||||||
|
pub timeout: Duration, // Default: 30s (large library queries can be slow)
|
||||||
|
pub max_retries: u32, // Default: 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
|
||||||
|
|
||||||
|
**Retry Strategy:**
|
||||||
|
- Retry delays: 1s, 2s, 4s (exponential backoff)
|
||||||
|
- Retries on: Network errors, 5xx server errors
|
||||||
|
- No retry on: 4xx client errors, 401/403 authentication errors
|
||||||
|
|
||||||
|
**Error Classification:**
|
||||||
|
```rust
|
||||||
|
pub enum ErrorKind {
|
||||||
|
Network, // Connection failures, timeouts, DNS errors
|
||||||
|
Authentication, // 401/403 responses
|
||||||
|
Server, // 5xx server errors
|
||||||
|
Client, // Other 4xx errors
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connectivity Monitor
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/connectivity/mod.rs`
|
||||||
|
|
||||||
|
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
|
||||||
|
|
||||||
|
### Source of truth: repository traffic
|
||||||
|
|
||||||
|
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
|
||||||
|
|
||||||
|
| Repository outcome | Meaning | Effect on reachability |
|
||||||
|
|--------------------|---------|------------------------|
|
||||||
|
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
|
||||||
|
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
|
||||||
|
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
|
||||||
|
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
|
||||||
|
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
|
||||||
|
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
|
||||||
|
|
||||||
|
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
|
||||||
|
|
||||||
|
### Time-window debounce (offline) + instant recovery (online)
|
||||||
|
|
||||||
|
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
|
||||||
|
|
||||||
|
- On the **first** `Network` failure, the monitor records `first_failure_at`.
|
||||||
|
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
|
||||||
|
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
|
||||||
|
|
||||||
|
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
|
||||||
|
|
||||||
|
### Offline-only recovery probe
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
|
||||||
|
Monitor --> State{"is_server_reachable?"}
|
||||||
|
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
|
||||||
|
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
|
||||||
|
Probe -->|"reachable again"| Monitor
|
||||||
|
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
|
||||||
|
Emit --> Frontend["Frontend Store → banner"]
|
||||||
|
```
|
||||||
|
|
||||||
|
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Traffic-driven**: Reachability follows the requests the user actually makes.
|
||||||
|
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
|
||||||
|
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
|
||||||
|
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
|
||||||
|
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
|
||||||
|
|
||||||
|
**Tauri Commands:**
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
|
||||||
|
| `connectivity_set_server_url` | Update monitored server URL |
|
||||||
|
| `connectivity_get_status` | Get current connectivity status |
|
||||||
|
| `connectivity_start_monitoring` | Start the offline recovery probe |
|
||||||
|
| `connectivity_stop_monitoring` | Stop the probe |
|
||||||
|
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
|
||||||
|
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
|
||||||
|
|
||||||
|
**Frontend Integration:**
|
||||||
|
```typescript
|
||||||
|
// The store is a pure reflection of backend events — it no longer decides
|
||||||
|
// reachability itself. navigator.onLine is advisory: it triggers an immediate
|
||||||
|
// recheck rather than forcing the offline state.
|
||||||
|
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
||||||
|
updateConnectivityState(event.payload.isReachable);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Resilience Architecture
|
||||||
|
|
||||||
|
The connectivity system provides resilience through multiple layers:
|
||||||
|
|
||||||
|
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
||||||
|
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
|
||||||
|
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
|
||||||
|
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
|
||||||
|
|
||||||
|
**Design Principles:**
|
||||||
|
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
|
||||||
|
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
|
||||||
|
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
|
||||||
|
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
|
||||||
|
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
|
||||||
|
- **Event-Driven**: Frontend reacts to connectivity changes via events.
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
# Offline Database Design
|
||||||
|
|
||||||
|
## Entity Relationship Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
servers ||--o{ users : "has"
|
||||||
|
servers ||--o{ libraries : "has"
|
||||||
|
libraries ||--o{ items : "contains"
|
||||||
|
items ||--o{ items : "parent_of"
|
||||||
|
items ||--o{ user_data : "has"
|
||||||
|
items ||--o{ downloads : "has"
|
||||||
|
items ||--o{ media_streams : "has"
|
||||||
|
items ||--o{ thumbnails : "has"
|
||||||
|
users ||--o{ user_data : "owns"
|
||||||
|
users ||--o{ downloads : "owns"
|
||||||
|
users ||--o{ sync_queue : "owns"
|
||||||
|
|
||||||
|
servers {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id UK
|
||||||
|
string name
|
||||||
|
string url
|
||||||
|
string version
|
||||||
|
datetime last_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
users {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
string name
|
||||||
|
boolean is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
libraries {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
string name
|
||||||
|
string collection_type
|
||||||
|
string image_tag
|
||||||
|
}
|
||||||
|
|
||||||
|
items {
|
||||||
|
int id PK
|
||||||
|
string jellyfin_id
|
||||||
|
int server_id FK
|
||||||
|
int library_id FK
|
||||||
|
int parent_id FK
|
||||||
|
string type
|
||||||
|
string name
|
||||||
|
string sort_name
|
||||||
|
string overview
|
||||||
|
int production_year
|
||||||
|
float community_rating
|
||||||
|
string official_rating
|
||||||
|
int runtime_ticks
|
||||||
|
string primary_image_tag
|
||||||
|
string backdrop_image_tag
|
||||||
|
string album_id
|
||||||
|
string album_name
|
||||||
|
string album_artist
|
||||||
|
json artists
|
||||||
|
json genres
|
||||||
|
int index_number
|
||||||
|
int parent_index_number
|
||||||
|
string premiere_date
|
||||||
|
json metadata_json
|
||||||
|
datetime created_at
|
||||||
|
datetime updated_at
|
||||||
|
datetime last_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int user_id FK
|
||||||
|
int position_ticks
|
||||||
|
int play_count
|
||||||
|
boolean is_favorite
|
||||||
|
boolean played
|
||||||
|
datetime last_played
|
||||||
|
datetime updated_at
|
||||||
|
datetime synced_at
|
||||||
|
}
|
||||||
|
|
||||||
|
downloads {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int user_id FK
|
||||||
|
string file_path
|
||||||
|
int file_size
|
||||||
|
string status
|
||||||
|
float progress
|
||||||
|
int priority
|
||||||
|
string error_message
|
||||||
|
datetime created_at
|
||||||
|
datetime completed_at
|
||||||
|
}
|
||||||
|
|
||||||
|
media_streams {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
int stream_index
|
||||||
|
string type
|
||||||
|
string codec
|
||||||
|
string language
|
||||||
|
string display_title
|
||||||
|
boolean is_default
|
||||||
|
boolean is_forced
|
||||||
|
boolean is_external
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_queue {
|
||||||
|
int id PK
|
||||||
|
int user_id FK
|
||||||
|
string operation
|
||||||
|
string entity_type
|
||||||
|
string entity_id
|
||||||
|
json payload
|
||||||
|
datetime created_at
|
||||||
|
int attempts
|
||||||
|
datetime last_attempt
|
||||||
|
string status
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnails {
|
||||||
|
int id PK
|
||||||
|
int item_id FK
|
||||||
|
string image_type
|
||||||
|
string image_tag
|
||||||
|
string file_path
|
||||||
|
int width
|
||||||
|
int height
|
||||||
|
datetime cached_at
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table Definitions
|
||||||
|
|
||||||
|
### servers
|
||||||
|
Stores connected Jellyfin server information.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
last_sync DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### users
|
||||||
|
Stores user accounts per server. Access tokens are stored separately in secure storage (see [09-security.md](09-security.md)).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### libraries
|
||||||
|
Stores library/collection metadata.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE libraries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
collection_type TEXT,
|
||||||
|
image_tag TEXT,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
last_sync DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_libraries_server ON libraries(server_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### items
|
||||||
|
Main table for all media items (movies, episodes, albums, songs, etc.).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT NOT NULL,
|
||||||
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
library_id INTEGER REFERENCES libraries(id) ON DELETE SET NULL,
|
||||||
|
parent_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Basic metadata
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sort_name TEXT,
|
||||||
|
overview TEXT,
|
||||||
|
|
||||||
|
-- Media info
|
||||||
|
production_year INTEGER,
|
||||||
|
community_rating REAL,
|
||||||
|
official_rating TEXT,
|
||||||
|
runtime_ticks INTEGER,
|
||||||
|
|
||||||
|
-- Images
|
||||||
|
primary_image_tag TEXT,
|
||||||
|
backdrop_image_tag TEXT,
|
||||||
|
|
||||||
|
-- Audio-specific
|
||||||
|
album_id TEXT,
|
||||||
|
album_name TEXT,
|
||||||
|
album_artist TEXT,
|
||||||
|
artists TEXT, -- JSON array
|
||||||
|
|
||||||
|
-- Series/Season-specific
|
||||||
|
index_number INTEGER,
|
||||||
|
parent_index_number INTEGER,
|
||||||
|
series_id TEXT,
|
||||||
|
series_name TEXT,
|
||||||
|
season_id TEXT,
|
||||||
|
|
||||||
|
-- Additional
|
||||||
|
genres TEXT, -- JSON array
|
||||||
|
premiere_date TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
|
||||||
|
-- Sync tracking
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_sync DATETIME,
|
||||||
|
|
||||||
|
UNIQUE(jellyfin_id, server_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance indexes
|
||||||
|
CREATE INDEX idx_items_server ON items(server_id);
|
||||||
|
CREATE INDEX idx_items_library ON items(library_id);
|
||||||
|
CREATE INDEX idx_items_parent ON items(parent_id);
|
||||||
|
CREATE INDEX idx_items_type ON items(type);
|
||||||
|
CREATE INDEX idx_items_album ON items(album_id);
|
||||||
|
CREATE INDEX idx_items_series ON items(series_id);
|
||||||
|
CREATE INDEX idx_items_name ON items(name COLLATE NOCASE);
|
||||||
|
|
||||||
|
-- Full-text search
|
||||||
|
CREATE VIRTUAL TABLE items_fts USING fts5(
|
||||||
|
name,
|
||||||
|
overview,
|
||||||
|
artists,
|
||||||
|
album_name,
|
||||||
|
album_artist,
|
||||||
|
content='items',
|
||||||
|
content_rowid='id'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Triggers to keep FTS in sync
|
||||||
|
CREATE TRIGGER items_ai AFTER INSERT ON items BEGIN
|
||||||
|
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER items_ad AFTER DELETE ON items BEGIN
|
||||||
|
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER items_au AFTER UPDATE ON items BEGIN
|
||||||
|
INSERT INTO items_fts(items_fts, rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES ('delete', old.id, old.name, old.overview, old.artists, old.album_name, old.album_artist);
|
||||||
|
INSERT INTO items_fts(rowid, name, overview, artists, album_name, album_artist)
|
||||||
|
VALUES (new.id, new.name, new.overview, new.artists, new.album_name, new.album_artist);
|
||||||
|
END;
|
||||||
|
```
|
||||||
|
|
||||||
|
### media_streams
|
||||||
|
Stores subtitle and audio track information for items.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE media_streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
stream_index INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
codec TEXT,
|
||||||
|
language TEXT,
|
||||||
|
display_title TEXT,
|
||||||
|
is_default BOOLEAN DEFAULT 0,
|
||||||
|
is_forced BOOLEAN DEFAULT 0,
|
||||||
|
is_external BOOLEAN DEFAULT 0,
|
||||||
|
path TEXT,
|
||||||
|
UNIQUE(item_id, stream_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_media_streams_item ON media_streams(item_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_data
|
||||||
|
Stores per-user data for items (favorites, progress, play count).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Playback state
|
||||||
|
position_ticks INTEGER DEFAULT 0,
|
||||||
|
play_count INTEGER DEFAULT 0,
|
||||||
|
played BOOLEAN DEFAULT 0,
|
||||||
|
last_played DATETIME,
|
||||||
|
|
||||||
|
-- User preferences
|
||||||
|
is_favorite BOOLEAN DEFAULT 0,
|
||||||
|
user_rating REAL,
|
||||||
|
|
||||||
|
-- Sync tracking
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced_at DATETIME,
|
||||||
|
needs_sync BOOLEAN DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_data_item ON user_data(item_id);
|
||||||
|
CREATE INDEX idx_user_data_user ON user_data(user_id);
|
||||||
|
CREATE INDEX idx_user_data_needs_sync ON user_data(needs_sync) WHERE needs_sync = 1;
|
||||||
|
CREATE INDEX idx_user_data_favorites ON user_data(user_id, is_favorite) WHERE is_favorite = 1;
|
||||||
|
CREATE INDEX idx_user_data_in_progress ON user_data(user_id, position_ticks)
|
||||||
|
WHERE position_ticks > 0 AND played = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### downloads
|
||||||
|
Tracks downloaded media files.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
file_path TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
file_hash TEXT,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
progress REAL DEFAULT 0,
|
||||||
|
bytes_downloaded INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
transcode_profile TEXT,
|
||||||
|
|
||||||
|
priority INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
started_at DATETIME,
|
||||||
|
completed_at DATETIME,
|
||||||
|
expires_at DATETIME,
|
||||||
|
|
||||||
|
UNIQUE(item_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_downloads_status ON downloads(status);
|
||||||
|
CREATE INDEX idx_downloads_user ON downloads(user_id);
|
||||||
|
CREATE INDEX idx_downloads_queue ON downloads(status, priority DESC, created_at ASC)
|
||||||
|
WHERE status IN ('pending', 'downloading');
|
||||||
|
```
|
||||||
|
|
||||||
|
### sync_queue
|
||||||
|
Stores mutations to sync back to server when online.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sync_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
payload TEXT,
|
||||||
|
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
max_attempts INTEGER DEFAULT 5,
|
||||||
|
last_attempt DATETIME,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sync_queue_status ON sync_queue(status, created_at ASC)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
CREATE INDEX idx_sync_queue_user ON sync_queue(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### thumbnails
|
||||||
|
Caches downloaded artwork.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE thumbnails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
image_type TEXT NOT NULL,
|
||||||
|
image_tag TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
file_size INTEGER,
|
||||||
|
cached_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(item_id, image_type, width)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_thumbnails_item ON thumbnails(item_id);
|
||||||
|
CREATE INDEX idx_thumbnails_lru ON thumbnails(last_accessed ASC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### playlists (for local/synced playlists)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jellyfin_id TEXT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_local_only BOOLEAN DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
synced_at DATETIME,
|
||||||
|
needs_sync BOOLEAN DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(playlist_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id, sort_order);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Queries
|
||||||
|
|
||||||
|
### Get items for offline library browsing
|
||||||
|
```sql
|
||||||
|
-- Get all albums in a music library
|
||||||
|
SELECT * FROM items
|
||||||
|
WHERE library_id = ? AND type = 'MusicAlbum'
|
||||||
|
ORDER BY sort_name;
|
||||||
|
|
||||||
|
-- Get tracks for an album
|
||||||
|
SELECT * FROM items
|
||||||
|
WHERE album_id = ? AND type = 'Audio'
|
||||||
|
ORDER BY parent_index_number, index_number;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resume / Continue Watching
|
||||||
|
```sql
|
||||||
|
SELECT i.*, ud.position_ticks, ud.last_played
|
||||||
|
FROM items i
|
||||||
|
JOIN user_data ud ON ud.item_id = i.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
AND ud.position_ticks > 0
|
||||||
|
AND ud.played = 0
|
||||||
|
ORDER BY ud.last_played DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline search
|
||||||
|
```sql
|
||||||
|
SELECT i.* FROM items i
|
||||||
|
JOIN items_fts fts ON fts.rowid = i.id
|
||||||
|
WHERE items_fts MATCH ?
|
||||||
|
ORDER BY rank;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download queue management
|
||||||
|
```sql
|
||||||
|
-- Get next item to download
|
||||||
|
SELECT d.*, i.name, i.type
|
||||||
|
FROM downloads d
|
||||||
|
JOIN items i ON i.id = d.item_id
|
||||||
|
WHERE d.status = 'pending'
|
||||||
|
ORDER BY d.priority DESC, d.created_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Get download progress for UI
|
||||||
|
SELECT
|
||||||
|
d.status,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(d.file_size) as total_size,
|
||||||
|
SUM(d.bytes_downloaded) as downloaded
|
||||||
|
FROM downloads d
|
||||||
|
WHERE d.user_id = ?
|
||||||
|
GROUP BY d.status;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync queue processing
|
||||||
|
```sql
|
||||||
|
-- Get pending sync operations (oldest first)
|
||||||
|
SELECT * FROM sync_queue
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND attempts < max_attempts
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 10;
|
||||||
|
|
||||||
|
-- Mark operation complete
|
||||||
|
UPDATE sync_queue
|
||||||
|
SET status = 'completed', completed_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Online Mode
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OnlineMode["Online Mode"]
|
||||||
|
JellyfinServer["Jellyfin Server"]
|
||||||
|
OnlineRepo["OnlineRepo"]
|
||||||
|
SQLite["SQLite"]
|
||||||
|
HybridRepo["HybridRepository"]
|
||||||
|
UI["UI / Stores"]
|
||||||
|
|
||||||
|
JellyfinServer -->|"API Response"| OnlineRepo
|
||||||
|
OnlineRepo -->|"Cache"| SQLite
|
||||||
|
SQLite -->|"Sync"| JellyfinServer
|
||||||
|
OnlineRepo -->|"Response"| HybridRepo
|
||||||
|
SQLite -->|"Fallback"| HybridRepo
|
||||||
|
HybridRepo --> UI
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Offline Mode
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OfflineMode["Offline Mode"]
|
||||||
|
OfflineRepo["OfflineRepo"]
|
||||||
|
SQLite2["SQLite"]
|
||||||
|
SyncQueue["sync_queue<br/>(Queued for later)"]
|
||||||
|
HybridRepo2["HybridRepository"]
|
||||||
|
UI2["UI / Stores"]
|
||||||
|
|
||||||
|
OfflineRepo <-->|"Query"| SQLite2
|
||||||
|
SQLite2 -->|"Mutations"| SyncQueue
|
||||||
|
OfflineRepo --> HybridRepo2
|
||||||
|
HybridRepo2 --> UI2
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync on Reconnect
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
NetworkRestored["Network restored"]
|
||||||
|
SyncService["SyncService"]
|
||||||
|
SyncQueue2["sync_queue"]
|
||||||
|
JellyfinAPI["Jellyfin API"]
|
||||||
|
MarkSynced["Mark synced"]
|
||||||
|
|
||||||
|
NetworkRestored --> SyncService
|
||||||
|
SyncService -->|"Read"| SyncQueue2
|
||||||
|
SyncQueue2 -->|"Send"| JellyfinAPI
|
||||||
|
JellyfinAPI -->|"Success"| MarkSynced
|
||||||
|
MarkSynced --> SyncService
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Estimates
|
||||||
|
|
||||||
|
| Content Type | Metadata Size | Thumbnail Size | Media Size |
|
||||||
|
|--------------|---------------|----------------|------------|
|
||||||
|
| Song | ~2 KB | ~50 KB (300px) | 5-15 MB |
|
||||||
|
| Album (12 tracks) | ~30 KB | ~100 KB | 60-180 MB |
|
||||||
|
| Movie | ~5 KB | ~200 KB | 1-8 GB |
|
||||||
|
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
||||||
|
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
||||||
|
|
||||||
|
## Rust Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/storage/
|
||||||
|
├── mod.rs # Module exports, Database struct
|
||||||
|
├── schema.rs # Table definitions, migrations
|
||||||
|
├── models.rs # Rust structs matching tables
|
||||||
|
├── queries/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── items.rs # Item CRUD operations
|
||||||
|
│ ├── user_data.rs # User data operations
|
||||||
|
│ ├── downloads.rs # Download queue operations
|
||||||
|
│ └── sync.rs # Sync queue operations
|
||||||
|
└── sync/
|
||||||
|
├── mod.rs # SyncService
|
||||||
|
├── manager.rs # Background sync manager
|
||||||
|
└── operations.rs # Individual sync operation handlers
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Authentication Token Storage
|
||||||
|
|
||||||
|
Access tokens are **not** stored in the SQLite database. Instead, they are stored using platform-native secure storage:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
LoginSuccess["Login Success"]
|
||||||
|
KeyringCheck{"System Keyring<br/>Available?"}
|
||||||
|
OSCredential["Store in OS Credential Manager<br/>- Linux: libsecret/GNOME Keyring<br/>- macOS: Keychain<br/>- Windows: Credential Manager<br/>- Android: EncryptedSharedPrefs"]
|
||||||
|
EncryptedFallback["Encrypted File Fallback<br/>(AES-256-GCM)"]
|
||||||
|
|
||||||
|
LoginSuccess --> KeyringCheck
|
||||||
|
KeyringCheck -->|"Yes"| OSCredential
|
||||||
|
KeyringCheck -->|"No"| EncryptedFallback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Format:**
|
||||||
|
```
|
||||||
|
jellytau::{server_id}::{user_id}::access_token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Tokens in SQLite would be readable if the database file is accessed
|
||||||
|
- System keyrings provide OS-level encryption and access control
|
||||||
|
- Fallback ensures functionality on minimal systems without a keyring daemon
|
||||||
|
|
||||||
|
## Secure Storage Module
|
||||||
|
|
||||||
|
**Location**: `src-tauri/src/secure_storage/` (planned)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait SecureStorage: Send + Sync {
|
||||||
|
fn store(&self, key: &str, value: &str) -> Result<(), SecureStorageError>;
|
||||||
|
fn retrieve(&self, key: &str) -> Result<Option<String>, SecureStorageError>;
|
||||||
|
fn delete(&self, key: &str) -> Result<(), SecureStorageError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform implementations
|
||||||
|
pub struct KeyringStorage; // Uses keyring crate
|
||||||
|
pub struct EncryptedFileStorage; // AES-256-GCM fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Security
|
||||||
|
|
||||||
|
| Aspect | Implementation |
|
||||||
|
|--------|----------------|
|
||||||
|
| Transport | HTTPS required for all Jellyfin API calls |
|
||||||
|
| Certificate Validation | System CA store (configurable for self-signed) |
|
||||||
|
| Token Transmission | Bearer token in `Authorization` header only |
|
||||||
|
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
|
||||||
|
|
||||||
|
## Local Data Protection
|
||||||
|
|
||||||
|
| Data Type | Protection |
|
||||||
|
|-----------|------------|
|
||||||
|
| Access Tokens | System keyring or encrypted file |
|
||||||
|
| Database (SQLite) | Plaintext (metadata only, no secrets) |
|
||||||
|
| Downloaded Media | Filesystem permissions only |
|
||||||
|
| Cached Thumbnails | Filesystem permissions only |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **No Secrets in SQLite**: The database contains only non-sensitive metadata
|
||||||
|
2. **Token Isolation**: Each user/server combination has a separate token entry
|
||||||
|
3. **Logout Cleanup**: Token deletion from secure storage on logout
|
||||||
|
4. **No Token Logging**: Tokens are never written to logs or debug output
|
||||||
|
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# JellyTau Software Architecture
|
||||||
|
|
||||||
|
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
|
**Last Updated:** 2026-06-20
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction.
|
||||||
|
|
||||||
|
### Architecture Principles
|
||||||
|
|
||||||
|
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
||||||
|
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
||||||
|
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
||||||
|
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
||||||
|
- **Cache-First**: Parallel queries with intelligent fallback.
|
||||||
|
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
|
||||||
|
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
||||||
|
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["Svelte Frontend"]
|
||||||
|
subgraph Stores["Stores (Thin Wrappers)"]
|
||||||
|
auth["auth"]
|
||||||
|
player["player"]
|
||||||
|
queue["queue"]
|
||||||
|
library["library"]
|
||||||
|
connectivity["connectivity"]
|
||||||
|
playbackMode["playbackMode"]
|
||||||
|
end
|
||||||
|
subgraph Components
|
||||||
|
playerComp["player/"]
|
||||||
|
libraryComp["library/"]
|
||||||
|
Search["Search"]
|
||||||
|
end
|
||||||
|
subgraph Routes
|
||||||
|
routeLibrary["/library"]
|
||||||
|
routePlayer["/player"]
|
||||||
|
routeRoot["/"]
|
||||||
|
end
|
||||||
|
subgraph API["API Layer (Thin Client)"]
|
||||||
|
RepositoryClient["RepositoryClient<br/>(Handle-based)"]
|
||||||
|
JellyfinClient["JellyfinClient<br/>(Helper)"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Frontend -->|"Tauri IPC (invoke)"| Backend
|
||||||
|
|
||||||
|
subgraph Backend["Rust Backend (Business Logic)"]
|
||||||
|
subgraph Commands["Tauri Commands (90+)"]
|
||||||
|
PlayerCmds["player.rs"]
|
||||||
|
RepoCmds["repository.rs (27)"]
|
||||||
|
PlaybackModeCmds["playback_mode.rs (5)"]
|
||||||
|
StorageCmds["storage.rs"]
|
||||||
|
ConnectivityCmds["connectivity.rs (7)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core["Core Modules"]
|
||||||
|
MediaSessionManager["MediaSessionManager<br/>(Audio/Movie/TvShow/Idle)"]
|
||||||
|
|
||||||
|
PlayerController["PlayerController<br/>+ PlayerBackend<br/>+ QueueManager"]
|
||||||
|
|
||||||
|
Repository["Repository Layer<br/>HybridRepository (cache-first)<br/>OnlineRepository (HTTP)<br/>OfflineRepository (SQLite)"]
|
||||||
|
|
||||||
|
PlaybackModeManager["PlaybackModeManager<br/>(Local/Remote/Idle)"]
|
||||||
|
|
||||||
|
ConnectivityMonitor["ConnectivityMonitor<br/>(Adaptive polling)"]
|
||||||
|
|
||||||
|
HttpClient["HttpClient<br/>(Exponential backoff retry)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Storage["Storage Layer"]
|
||||||
|
DatabaseService["DatabaseService<br/>(Async trait)"]
|
||||||
|
SQLite["SQLite Database<br/>(13 tables)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Commands --> Core
|
||||||
|
Core --> Storage
|
||||||
|
Repository --> HttpClient
|
||||||
|
Repository --> DatabaseService
|
||||||
|
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Documentation
|
||||||
|
|
||||||
|
Each major subsystem is documented in its own file in this directory:
|
||||||
|
|
||||||
|
| Document | Contents |
|
||||||
|
|----------|----------|
|
||||||
|
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
|
||||||
|
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
|
||||||
|
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
||||||
|
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
||||||
|
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
||||||
|
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
|
||||||
|
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
||||||
|
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
||||||
|
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/
|
||||||
|
├── lib.rs # Tauri app setup, state initialization
|
||||||
|
├── commands/ # Tauri command handlers (90+ commands)
|
||||||
|
│ ├── mod.rs # Command exports
|
||||||
|
│ ├── player.rs # 16 player commands
|
||||||
|
│ ├── repository.rs # 27 repository commands
|
||||||
|
│ ├── playlist.rs # 7 playlist commands
|
||||||
|
│ ├── playback_mode.rs # 5 playback mode commands
|
||||||
|
│ ├── connectivity.rs # 7 connectivity commands
|
||||||
|
│ ├── storage.rs # Storage & database commands
|
||||||
|
│ ├── download.rs # 7 download commands
|
||||||
|
│ ├── offline.rs # 3 offline commands
|
||||||
|
│ └── sync.rs # Sync queue commands
|
||||||
|
├── repository/ # Repository pattern implementation
|
||||||
|
│ ├── mod.rs # MediaRepository trait, handle management
|
||||||
|
│ ├── types.rs # RepoError, Library, MediaItem, etc.
|
||||||
|
│ ├── hybrid.rs # HybridRepository with cache-first racing
|
||||||
|
│ ├── online.rs # OnlineRepository (HTTP API)
|
||||||
|
│ └── offline.rs # OfflineRepository (SQLite queries)
|
||||||
|
├── playback_mode/ # Playback mode manager
|
||||||
|
│ └── mod.rs # PlaybackMode enum, transfer logic
|
||||||
|
├── connectivity/ # Connectivity monitoring
|
||||||
|
│ └── mod.rs # ConnectivityMonitor, adaptive polling
|
||||||
|
├── jellyfin/ # Jellyfin API client
|
||||||
|
│ ├── mod.rs # Module exports
|
||||||
|
│ ├── http_client.rs # HTTP client with retry logic
|
||||||
|
│ └── client.rs # JellyfinClient for API calls
|
||||||
|
├── storage/ # Database layer
|
||||||
|
│ ├── mod.rs # Database struct, migrations
|
||||||
|
│ ├── db_service.rs # DatabaseService trait (async wrapper)
|
||||||
|
│ ├── schema.rs # Table definitions
|
||||||
|
│ └── queries/ # Query modules
|
||||||
|
├── download/ # Download manager module
|
||||||
|
│ ├── mod.rs # DownloadManager, DownloadInfo, DownloadTask
|
||||||
|
│ ├── worker.rs # DownloadWorker, HTTP streaming, retry logic
|
||||||
|
│ ├── events.rs # DownloadEvent enum
|
||||||
|
│ └── cache.rs # SmartCache, CacheConfig, LRU eviction
|
||||||
|
└── player/ # Player subsystem
|
||||||
|
├── mod.rs # PlayerController
|
||||||
|
├── session.rs # MediaSessionManager, MediaSessionType
|
||||||
|
├── state.rs # PlayerState, PlayerEvent
|
||||||
|
├── media.rs # MediaItem, MediaSource, MediaType
|
||||||
|
├── queue.rs # QueueManager, RepeatMode
|
||||||
|
├── backend.rs # PlayerBackend trait, NullBackend
|
||||||
|
├── events.rs # PlayerStatusEvent, TauriEventEmitter
|
||||||
|
├── mpv/ # Linux MPV backend
|
||||||
|
│ ├── mod.rs # MpvBackend implementation
|
||||||
|
│ └── event_loop.rs # Dedicated thread for MPV operations
|
||||||
|
└── android/ # Android ExoPlayer backend
|
||||||
|
└── mod.rs # ExoPlayerBackend + JNI bindings
|
||||||
|
|
||||||
|
src/lib/
|
||||||
|
├── api/ # Thin API layer (~200 lines total)
|
||||||
|
│ ├── types.ts # TypeScript type definitions
|
||||||
|
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
||||||
|
│ ├── client.ts # JellyfinClient (helper for streaming)
|
||||||
|
│ └── sessions.ts # SessionsApi (remote session control)
|
||||||
|
├── services/
|
||||||
|
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||||
|
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||||
|
├── stores/ # Thin reactive wrappers over Rust commands
|
||||||
|
│ ├── index.ts # Re-exports
|
||||||
|
│ ├── auth.ts # Auth store (calls Rust commands)
|
||||||
|
│ ├── player.ts # Player store
|
||||||
|
│ ├── queue.ts # Queue store
|
||||||
|
│ ├── library.ts # Library store
|
||||||
|
│ ├── playbackMode.ts # Playback mode store (~150 lines)
|
||||||
|
│ ├── connectivity.ts # Connectivity store (~250 lines)
|
||||||
|
│ └── downloads.ts # Downloads store with event listeners
|
||||||
|
└── components/
|
||||||
|
├── Search.svelte
|
||||||
|
├── player/ # Player UI components
|
||||||
|
├── playlist/ # Playlist modals (Create, AddTo)
|
||||||
|
├── sessions/ # Remote session control UI
|
||||||
|
├── downloads/ # Download UI components
|
||||||
|
└── library/ # Library UI components + PlaylistDetailView
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Architecture Changes
|
||||||
|
|
||||||
|
**What moved to Rust (~3,500 lines of business logic):**
|
||||||
|
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
||||||
|
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
|
||||||
|
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
|
||||||
|
4. **Database Service** - Async wrapper preventing UI freezing
|
||||||
|
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
||||||
|
|
||||||
|
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):**
|
||||||
|
- Components + routes (~14.6k lines) — UI and presentation
|
||||||
|
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events
|
||||||
|
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers
|
||||||
|
|
||||||
|
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state.
|
||||||
|
|
||||||
|
**Total Commands:** 90+ Tauri commands across 14 command modules
|
||||||
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,347 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 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/)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# 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)
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Release Checklist
|
||||||
|
|
||||||
|
Quick reference for creating a JellyTau release.
|
||||||
|
|
||||||
|
## Pre-Release (1-2 days before)
|
||||||
|
|
||||||
|
- [ ] Code is on `master`/`main` branch
|
||||||
|
- [ ] All feature branches are merged and tested
|
||||||
|
- [ ] No failing tests locally: `bun run test` and `bun run test:rust`
|
||||||
|
- [ ] Requirement traceability check passes: `bun run traces:json`
|
||||||
|
- [ ] Type checking passes: `bun run check`
|
||||||
|
|
||||||
|
## Update Version (Day before)
|
||||||
|
|
||||||
|
- [ ] Decide on version number (semantic versioning)
|
||||||
|
- Example: `v1.2.0` (major.minor.patch)
|
||||||
|
- Example: `v1.0.0-rc1` (release candidate)
|
||||||
|
- Example: `v1.0.0-beta` (beta)
|
||||||
|
|
||||||
|
- [ ] Update version in files:
|
||||||
|
```bash
|
||||||
|
# Check these files for version numbers
|
||||||
|
cat package.json | grep version
|
||||||
|
cat src-tauri/tauri.conf.json | grep version
|
||||||
|
cat src-tauri/Cargo.toml | grep version
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Update `CHANGELOG.md`:
|
||||||
|
- [ ] Add section for new version
|
||||||
|
- [ ] List all features added
|
||||||
|
- [ ] List all bugs fixed
|
||||||
|
- [ ] List breaking changes (if any)
|
||||||
|
- [ ] Add upgrade instructions (if needed)
|
||||||
|
- [ ] Format: Markdown with clear sections
|
||||||
|
|
||||||
|
- [ ] Update `README.md`:
|
||||||
|
- [ ] Update any version references
|
||||||
|
- [ ] Update feature list if applicable
|
||||||
|
- [ ] Update requirements if changed
|
||||||
|
|
||||||
|
- [ ] Commit changes:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Bump version to v1.2.0"
|
||||||
|
git push origin master
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Check Before Release
|
||||||
|
|
||||||
|
- [ ] Run full test suite:
|
||||||
|
```bash
|
||||||
|
bun run test # Frontend tests
|
||||||
|
bun run test:rust # Rust tests
|
||||||
|
bun run check # Type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Build locally (optional but recommended):
|
||||||
|
```bash
|
||||||
|
# Test Linux build
|
||||||
|
bun run tauri build
|
||||||
|
|
||||||
|
# Test Android build
|
||||||
|
bun run tauri android build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] No uncommitted changes:
|
||||||
|
```bash
|
||||||
|
git status # Should show clean working directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release (Tag & Push)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create annotated tag with release notes
|
||||||
|
git tag -a v1.2.0 -m "Release version 1.2.0
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- New feature 1
|
||||||
|
- New feature 2
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
- Fixed bug 1
|
||||||
|
- Fixed bug 2
|
||||||
|
|
||||||
|
Improvements:
|
||||||
|
- Performance improvement 1
|
||||||
|
- UI improvement 1
|
||||||
|
|
||||||
|
Breaking Changes:
|
||||||
|
- None (or list if applicable)
|
||||||
|
|
||||||
|
Migration:
|
||||||
|
- No action required (or include steps if applicable)"
|
||||||
|
|
||||||
|
# 2. Push tag to trigger workflow
|
||||||
|
git push origin v1.2.0
|
||||||
|
|
||||||
|
# 3. Monitor in Gitea Actions
|
||||||
|
# Go to Actions tab and watch the workflow run
|
||||||
|
```
|
||||||
|
|
||||||
|
## During Release (While Workflow Runs)
|
||||||
|
|
||||||
|
- [ ] Watch workflow progress in Gitea Actions
|
||||||
|
- [ ] Monitor for test failures
|
||||||
|
- [ ] Monitor for build failures
|
||||||
|
- [ ] Check build logs if any step fails
|
||||||
|
|
||||||
|
## After Release (Workflow Complete)
|
||||||
|
|
||||||
|
- [ ] Download artifacts from release page:
|
||||||
|
- [ ] `jellytau_*.AppImage` (Linux)
|
||||||
|
- [ ] `jellytau_*.deb` (Linux)
|
||||||
|
- [ ] `jellytau-release.apk` (Android)
|
||||||
|
- [ ] `jellytau-release.aab` (Android)
|
||||||
|
|
||||||
|
- [ ] Basic testing of artifacts:
|
||||||
|
- [ ] Linux AppImage runs
|
||||||
|
- [ ] Linux DEB installs and runs
|
||||||
|
- [ ] Android APK installs (via `adb` or sideload)
|
||||||
|
|
||||||
|
- [ ] Verify release page:
|
||||||
|
- [ ] Title is correct: "JellyTau vX.Y.Z"
|
||||||
|
- [ ] Release notes are formatted correctly
|
||||||
|
- [ ] All artifacts are uploaded
|
||||||
|
- [ ] Release type is correct (prerelease vs release)
|
||||||
|
|
||||||
|
- [ ] Announce release:
|
||||||
|
- [ ] Post to relevant channels/communities
|
||||||
|
- [ ] Update website/docs
|
||||||
|
- [ ] Tag contributors if applicable
|
||||||
|
|
||||||
|
## Rollback (If Issues Found)
|
||||||
|
|
||||||
|
If critical issues are found after release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Delete tag locally and remotely
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin :refs/tags/v1.2.0
|
||||||
|
|
||||||
|
# Option 2: Mark as prerelease in release page
|
||||||
|
# Then plan immediate patch release (v1.2.1)
|
||||||
|
|
||||||
|
# Option 3: Create hotfix branch and release v1.2.1
|
||||||
|
git checkout -b hotfix/v1.2.1
|
||||||
|
# Fix issues
|
||||||
|
git commit -m "Fix critical issue"
|
||||||
|
git tag v1.2.1
|
||||||
|
git push origin hotfix/v1.2.1 v1.2.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Examples
|
||||||
|
|
||||||
|
### Major Release
|
||||||
|
```
|
||||||
|
v2.0.0 - Major version bump
|
||||||
|
- Significant new features
|
||||||
|
- Breaking API changes
|
||||||
|
- Major UI redesign
|
||||||
|
```
|
||||||
|
|
||||||
|
### Minor Release
|
||||||
|
```
|
||||||
|
v1.2.0 - Feature release
|
||||||
|
- New features
|
||||||
|
- Backward compatible
|
||||||
|
- Bug fixes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patch Release
|
||||||
|
```
|
||||||
|
v1.1.1 - Bug fix/patch
|
||||||
|
- Bug fixes only
|
||||||
|
- No new features
|
||||||
|
- Backward compatible
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-releases
|
||||||
|
```
|
||||||
|
v1.2.0-alpha - Early development
|
||||||
|
v1.2.0-beta - Late development, feature complete
|
||||||
|
v1.2.0-rc1 - Release candidate, minimal fixes only
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Locations
|
||||||
|
|
||||||
|
Key files for versioning:
|
||||||
|
- `package.json` - Frontend version
|
||||||
|
- `src-tauri/tauri.conf.json` - Tauri config version
|
||||||
|
- `src-tauri/Cargo.toml` - Rust version
|
||||||
|
- `CHANGELOG.md` - Release history
|
||||||
|
- `README.md` - Project documentation
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tests Fail Before Release
|
||||||
|
1. Don't push tag yet
|
||||||
|
2. Fix failing tests locally
|
||||||
|
3. Push fixes to master
|
||||||
|
4. Re-run test suite
|
||||||
|
5. Then tag and push
|
||||||
|
|
||||||
|
### Build Fails in CI
|
||||||
|
1. Check detailed logs in Gitea Actions
|
||||||
|
2. Fix issue locally
|
||||||
|
3. Delete tag: `git tag -d v1.2.0 && git push origin :refs/tags/v1.2.0`
|
||||||
|
4. Push fix to master
|
||||||
|
5. Create new tag with fix
|
||||||
|
|
||||||
|
### Release Already Exists
|
||||||
|
1. If workflow runs twice, artifacts may conflict
|
||||||
|
2. Check release page
|
||||||
|
3. If duplicates exist, delete and re-release
|
||||||
|
|
||||||
|
### Artifacts Missing
|
||||||
|
1. Check build logs for errors
|
||||||
|
2. Verify platform-specific dependencies
|
||||||
|
3. Delete tag and retry after fixes
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- Tests: ~5-10 minutes
|
||||||
|
- Linux build: ~10-15 minutes
|
||||||
|
- Android build: ~15-20 minutes
|
||||||
|
- Total release time: ~30-45 minutes
|
||||||
|
|
||||||
|
First build takes longer (cache warming). Subsequent releases are faster due to caching.
|
||||||
|
|
||||||
|
## Template: Release Notes
|
||||||
|
|
||||||
|
```
|
||||||
|
## 🎉 JellyTau vX.Y.Z
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
- New feature 1
|
||||||
|
- New feature 2
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
- Fixed issue #123
|
||||||
|
- Fixed issue #456
|
||||||
|
|
||||||
|
### 🚀 Performance
|
||||||
|
- Improvement 1
|
||||||
|
- Improvement 2
|
||||||
|
|
||||||
|
### 📱 Downloads
|
||||||
|
- [Linux AppImage](#) - Run on any Linux
|
||||||
|
- [Linux DEB](#) - Install on Ubuntu/Debian
|
||||||
|
- [Android APK](#) - Install on Android devices
|
||||||
|
- [Android AAB](#) - For Google Play Store
|
||||||
|
|
||||||
|
### 📋 Requirements
|
||||||
|
**Linux:** 64-bit, GLIBC 2.29+
|
||||||
|
**Android:** 8.0+
|
||||||
|
|
||||||
|
### 🔗 Links
|
||||||
|
- [Changelog](../../CHANGELOG.md)
|
||||||
|
- [Issues](../../issues)
|
||||||
|
- [Discussion](../../discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
Built with Tauri, SvelteKit, and Rust 🦀
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View existing tags
|
||||||
|
git tag -l
|
||||||
|
|
||||||
|
# Create release locally (dry run)
|
||||||
|
git tag -a v1.2.0 -m "Release v1.2.0" --dry-run
|
||||||
|
|
||||||
|
# List commits since last tag
|
||||||
|
git log v1.1.0..HEAD --oneline
|
||||||
|
|
||||||
|
# Show tag details
|
||||||
|
git show v1.2.0
|
||||||
|
|
||||||
|
# Rename tag (if needed)
|
||||||
|
git tag v1.2.0_old v1.2.0
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin v1.2.0_old v1.2.0
|
||||||
|
|
||||||
|
# Delete tag locally and remotely
|
||||||
|
git tag -d v1.2.0
|
||||||
|
git push origin :refs/tags/v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tips:**
|
||||||
|
- ✅ Always test locally before release
|
||||||
|
- ✅ Use semantic versioning consistently
|
||||||
|
- ✅ Document changes in CHANGELOG
|
||||||
|
- ✅ Wait for full workflow completion
|
||||||
|
- ✅ Test release artifacts before announcing
|
||||||
|
|
||||||
|
**Remember:** A good release is a tested release! 🚀
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
# Requirements Specification
|
||||||
|
|
||||||
|
This document captures JellyTau's user requirements, software requirements,
|
||||||
|
traceability matrix, test traceability, and known technical debt.
|
||||||
|
|
||||||
|
For a narrative overview of the system design, see
|
||||||
|
[docs/architecture/](architecture/). For development workflows, see the
|
||||||
|
[README](../README.md) and [scripts/README.md](../scripts/README.md).
|
||||||
|
|
||||||
|
## 1. User Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Status |
|
||||||
|
|----|-------------|----------|--------|
|
||||||
|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
||||||
|
| UR-002 | Access media when online or offline | High | Done |
|
||||||
|
| UR-003 | Play videos | High | Done |
|
||||||
|
| UR-004 | Play audio uninterrupted | High | Done |
|
||||||
|
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||||||
|
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||||||
|
| UR-007 | Navigate media in library | High | Done |
|
||||||
|
| UR-008 | Search media across libraries | High | Done |
|
||||||
|
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||||
|
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
||||||
|
| UR-011 | Download media on demand | Medium | Done |
|
||||||
|
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
||||||
|
| UR-013 | View and manage downloaded media | Medium | Done |
|
||||||
|
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
|
||||||
|
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
||||||
|
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
||||||
|
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
||||||
|
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
||||||
|
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
||||||
|
| UR-020 | Select subtitles for video content | High | Done |
|
||||||
|
| UR-021 | Select audio track for video content | High | Done |
|
||||||
|
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
||||||
|
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
||||||
|
| UR-024 | View recently added content on server | Medium | Done |
|
||||||
|
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||||
|
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||||||
|
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
||||||
|
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||||
|
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
|
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||||
|
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||||
|
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||||
|
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||||
|
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||||
|
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
||||||
|
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
||||||
|
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
||||||
|
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
||||||
|
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Software Requirements
|
||||||
|
|
||||||
|
### 2.1 Integration Requirements
|
||||||
|
|
||||||
|
External system integrations and platform-specific implementations.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
||||||
|
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||||||
|
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
||||||
|
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||||||
|
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||||||
|
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
||||||
|
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||||||
|
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||||||
|
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
||||||
|
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
||||||
|
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
||||||
|
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
||||||
|
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
||||||
|
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
||||||
|
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
||||||
|
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||||
|
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||||||
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
|
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||||
|
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
||||||
|
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||||
|
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||||
|
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||||||
|
|
||||||
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
|
API endpoints and data contracts required for Jellyfin integration.
|
||||||
|
|
||||||
|
| ID | Requirement | Endpoint Category | Traces To | Status |
|
||||||
|
|----|-------------|-------------------|-----------|--------|
|
||||||
|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
||||||
|
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
||||||
|
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
||||||
|
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
||||||
|
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
||||||
|
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
||||||
|
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
||||||
|
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
||||||
|
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
||||||
|
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
||||||
|
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
||||||
|
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
||||||
|
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
||||||
|
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
||||||
|
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
||||||
|
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
||||||
|
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
||||||
|
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
||||||
|
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
|
||||||
|
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
|
||||||
|
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
||||||
|
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
||||||
|
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
||||||
|
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
||||||
|
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
||||||
|
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
|
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||||
|
|
||||||
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
|
Internal architecture, components, and application logic.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
||||||
|
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
||||||
|
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
||||||
|
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
||||||
|
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
||||||
|
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
||||||
|
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
||||||
|
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
||||||
|
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
||||||
|
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
||||||
|
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
||||||
|
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
|
||||||
|
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
||||||
|
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
||||||
|
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
||||||
|
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
||||||
|
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
|
||||||
|
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
||||||
|
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
||||||
|
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
||||||
|
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
||||||
|
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
||||||
|
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
||||||
|
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
||||||
|
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
||||||
|
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
||||||
|
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||||||
|
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||||||
|
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||||||
|
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
||||||
|
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||||
|
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||||
|
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||||
|
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||||
|
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||||||
|
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||||||
|
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||||
|
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
||||||
|
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
||||||
|
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
||||||
|
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
||||||
|
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
||||||
|
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
||||||
|
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
||||||
|
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
||||||
|
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
||||||
|
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
||||||
|
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Traceability Matrix
|
||||||
|
|
||||||
|
### User Requirements to Software Requirements
|
||||||
|
|
||||||
|
| User Req | Integration Requirements | Development Requirements |
|
||||||
|
|----------|-------------------------|-------------------------|
|
||||||
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||||
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
||||||
|
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||||
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
|
| UR-010 | IR-012, IR-021 | DR-037 |
|
||||||
|
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||||
|
| UR-012 | IR-009, IR-014 | - |
|
||||||
|
| UR-013 | IR-013 | DR-017 |
|
||||||
|
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||||
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
|
| UR-016 | - | - |
|
||||||
|
| UR-017 | - | DR-014, DR-021 |
|
||||||
|
| UR-018 | IR-013 | DR-015, DR-018 |
|
||||||
|
| UR-019 | IR-015 | DR-022 |
|
||||||
|
| UR-020 | IR-016, IR-018 | DR-023 |
|
||||||
|
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||||
|
| UR-022 | IR-017 | DR-025 |
|
||||||
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||||
|
| UR-024 | IR-010 | DR-027 |
|
||||||
|
| UR-025 | IR-015 | DR-028 |
|
||||||
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
|
| UR-027 | IR-020 | DR-030 |
|
||||||
|
| UR-028 | - | DR-031 |
|
||||||
|
| UR-029 | - | DR-032 |
|
||||||
|
| UR-030 | IR-010 | DR-033 |
|
||||||
|
| UR-031 | - | DR-034 |
|
||||||
|
| UR-032 | - | DR-035 |
|
||||||
|
| UR-033 | - | DR-036 |
|
||||||
|
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
||||||
|
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
||||||
|
| UR-036 | IR-022, IR-023 | DR-041 |
|
||||||
|
| UR-037 | IR-010 | DR-042 |
|
||||||
|
| UR-038 | IR-010 | DR-043 |
|
||||||
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Test Traceability
|
||||||
|
|
||||||
|
### Unit Tests to Software Requirements
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| UT-001 | Player state transitions | DR-001 | Pending |
|
||||||
|
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
||||||
|
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
||||||
|
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
||||||
|
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
||||||
|
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
||||||
|
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
||||||
|
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
||||||
|
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
||||||
|
| UT-010 | Download queue management | DR-015 | Done |
|
||||||
|
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
||||||
|
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
||||||
|
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
||||||
|
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
||||||
|
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
||||||
|
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
||||||
|
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
||||||
|
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
||||||
|
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
||||||
|
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
||||||
|
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
||||||
|
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
||||||
|
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
||||||
|
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
||||||
|
| UT-027 | NullBackend set volume | DR-004 | Done |
|
||||||
|
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
||||||
|
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
||||||
|
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
||||||
|
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
||||||
|
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
||||||
|
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
||||||
|
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
||||||
|
| UT-035 | Download event completed serialization | DR-015 | Done |
|
||||||
|
| UT-036 | Download event failed serialization | DR-015 | Done |
|
||||||
|
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
||||||
|
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
||||||
|
| UT-039 | Download manager creation | DR-015 | Done |
|
||||||
|
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
||||||
|
| UT-041 | Download info serialization | DR-015 | Done |
|
||||||
|
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
||||||
|
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
||||||
|
| UT-044 | Offline item serialization | DR-017 | Done |
|
||||||
|
| UT-045 | Smart cache default config | DR-015 | Done |
|
||||||
|
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
||||||
|
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
||||||
|
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
||||||
|
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
||||||
|
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
||||||
|
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
||||||
|
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
||||||
|
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
||||||
|
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
||||||
|
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||||||
|
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||||
|
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||||||
|
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||||||
|
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||||||
|
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||||
|
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
|
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technical Debt
|
||||||
|
|
||||||
|
### Linux Keyring Integration Workaround
|
||||||
|
|
||||||
|
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
||||||
|
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
||||||
|
- Session restoration fails on app restart even though credentials exist
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
||||||
|
|
||||||
|
**Current Workaround**:
|
||||||
|
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
||||||
|
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
||||||
|
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
||||||
|
- **Delete**: `secret-tool clear service <service> username <username>`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
|
||||||
|
Linux-specific `secret-tool` save/get/delete paths.
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
- Monitor `keyring-rs` for bug fixes in future versions
|
||||||
|
- Consider alternative secure storage libraries
|
||||||
|
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
||||||
|
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - The workaround is functionally equivalent to proper keyring integration
|
||||||
|
- Credentials are stored securely in the system keyring
|
||||||
|
- Session restoration works correctly
|
||||||
|
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
||||||
|
- Already available on most Linux distributions by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Platform Playback Backend Parity (Linux vs Android)
|
||||||
|
|
||||||
|
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
||||||
|
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
||||||
|
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
||||||
|
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
|
||||||
|
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
||||||
|
|
||||||
|
**Feature Parity Matrix**:
|
||||||
|
|
||||||
|
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
||||||
|
|---------|-------------|---------------------|--------|
|
||||||
|
| Basic playback | ✅ | ✅ | Parity |
|
||||||
|
| Volume control | ✅ | ✅ | Parity |
|
||||||
|
| Seek | ✅ | ✅ | Parity |
|
||||||
|
| Crossfade | ✅ | ❌ | Gap |
|
||||||
|
| Gapless playback | ✅ | ❌ | Gap |
|
||||||
|
| Volume normalization | ✅ | ❌ | Gap |
|
||||||
|
| Position updates | 250ms | On-demand | Inconsistent |
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||||
|
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||||
|
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||||
|
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||||
|
5. Standardize position update frequency across platforms
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||||
|
- User experience differs between platforms
|
||||||
|
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||||
|
|
||||||
|
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Playback Code Duplication
|
||||||
|
|
||||||
|
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
||||||
|
- Same `$derived` state merging logic for local/remote playback in both components
|
||||||
|
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
||||||
|
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||||
|
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||||||
|
|
||||||
|
**Duplicated Code**:
|
||||||
|
```typescript
|
||||||
|
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
||||||
|
handlePlayPause(), handleNext(), handlePrevious(),
|
||||||
|
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
||||||
|
|
||||||
|
// These derived states use identical logic:
|
||||||
|
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Create `src/lib/utils/playbackUnits.ts`:
|
||||||
|
```typescript
|
||||||
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
||||||
|
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
||||||
|
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
||||||
|
- Single source of truth for merged local/remote state
|
||||||
|
|
||||||
|
3. Simplify handler wrappers using a utility:
|
||||||
|
```typescript
|
||||||
|
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
||||||
|
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - Code works correctly but violates DRY principle
|
||||||
|
- Maintenance burden when logic needs to change
|
||||||
|
- Risk of handlers diverging over time
|
||||||
|
|
||||||
|
**Traces To**: DR-009
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# TRACES Quick Reference Guide
|
||||||
|
|
||||||
|
## What are TRACES?
|
||||||
|
|
||||||
|
TRACES are requirement identifiers embedded in code comments to track which requirements are implemented where.
|
||||||
|
|
||||||
|
Format: `// TRACES: UR-001, UR-002 | DR-003`
|
||||||
|
|
||||||
|
## Quick Examples
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026 | DR-029
|
||||||
|
export function handlePlayback() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume playback from saved position
|
||||||
|
* TRACES: UR-019 | DR-022
|
||||||
|
*/
|
||||||
|
export async function resumePlayback(itemId: string) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Svelte
|
||||||
|
```svelte
|
||||||
|
<!-- TRACES: UR-007, UR-008 | DR-007 -->
|
||||||
|
<script>
|
||||||
|
export let items = [];
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
```rust
|
||||||
|
/// TRACES: UR-005 | DR-001
|
||||||
|
pub enum PlayerState { ... }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_queue_next() {
|
||||||
|
// TRACES: UR-005 | DR-005 | UT-003
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirement Types
|
||||||
|
|
||||||
|
| Type | Meaning | Example |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **UR** | User Requirement | UR-005: Control media playback |
|
||||||
|
| **IR** | Integration Requirement | IR-003: LibMPV integration |
|
||||||
|
| **DR** | Development Requirement | DR-001: Player state machine |
|
||||||
|
| **JA** | Jellyfin API Requirement | JA-007: Get playback info |
|
||||||
|
| **UT** | Unit Test | UT-001: Player state transitions |
|
||||||
|
| **IT** | Integration Test | IT-003: Audio playback via libmpv |
|
||||||
|
|
||||||
|
## Where to Find Requirements
|
||||||
|
|
||||||
|
1. **User Requirements (UR):** [README.md](README.md#1-user-requirements)
|
||||||
|
2. **Integration Requirements (IR):** [README.md](README.md#21-integration-requirements)
|
||||||
|
3. **Development Requirements (DR):** [README.md](README.md#23-development-requirements)
|
||||||
|
4. **Jellyfin API (JA):** [README.md](README.md#22-jellyfin-api-requirements)
|
||||||
|
|
||||||
|
## How to Add TRACES
|
||||||
|
|
||||||
|
### Step 1: Find the Requirement
|
||||||
|
Look up the requirement in README.md or the traceability matrix.
|
||||||
|
|
||||||
|
Example: `UR-005: Control media playback (pause, play, skip, scrub)`
|
||||||
|
|
||||||
|
### Step 2: Add Comment
|
||||||
|
Add TRACES comment at the top of the function/type/module:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005
|
||||||
|
export async function playMedia(itemId: string) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Run Extraction
|
||||||
|
Verify the trace is captured:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run traces:json | jq '.requirements | keys | grep "UR-005"'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Single Requirement
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005
|
||||||
|
function handlePlay() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Requirements, Same Type
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026, UR-019
|
||||||
|
function handlePlaybackState() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Types
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005, UR-026 | DR-029
|
||||||
|
function autoplayNextEpisode() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
```typescript
|
||||||
|
// TRACES: UR-005 | UT-001
|
||||||
|
#[test]
|
||||||
|
fn test_player_state_transition() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modules/Files
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Player event handling
|
||||||
|
* TRACES: UR-005, UR-019, UR-023 | DR-001, DR-028
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Check Your Changes
|
||||||
|
```bash
|
||||||
|
# View current coverage
|
||||||
|
bun run traces:json | jq '.byType'
|
||||||
|
|
||||||
|
# Generate full report
|
||||||
|
bun run traces:markdown
|
||||||
|
|
||||||
|
# Check specific requirement
|
||||||
|
bun run traces:json | jq '.requirements."UR-005"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Committing
|
||||||
|
1. Ensure all new code has TRACES
|
||||||
|
2. Format is correct: `// TRACES: ...`
|
||||||
|
3. Requirements exist in README.md
|
||||||
|
4. No typos in requirement IDs
|
||||||
|
|
||||||
|
## CI/CD Validation
|
||||||
|
|
||||||
|
The workflow automatically checks:
|
||||||
|
- ✅ Coverage stays >= 50%
|
||||||
|
- ✅ New files have TRACES
|
||||||
|
- ✅ JSON format is valid
|
||||||
|
- ✅ Reports are generated
|
||||||
|
|
||||||
|
See [traceability-ci.md](docs/traceability-ci.md) for details.
|
||||||
|
|
||||||
|
## Tips & Tricks
|
||||||
|
|
||||||
|
### Find Related Code
|
||||||
|
```bash
|
||||||
|
# Find all code tracing to UR-005
|
||||||
|
bun run traces:json | jq '.requirements."UR-005"'
|
||||||
|
|
||||||
|
# List all tests
|
||||||
|
bun run traces:json | jq '.requirements | keys | map(select(startswith("UT")))'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Your Editor
|
||||||
|
|
||||||
|
**VS Code:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"editor.wordBasedSuggestions": false,
|
||||||
|
"editor.suggest.custom": [
|
||||||
|
{
|
||||||
|
"name": "TRACES Format",
|
||||||
|
"insertText": "// TRACES: $1",
|
||||||
|
"insertTextRules": "InsertAsSnippet"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find Untraced Code
|
||||||
|
```bash
|
||||||
|
# Files modified without TRACES
|
||||||
|
git diff --name-only | xargs grep -L "TRACES:" | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Do I need TRACES on every function?**
|
||||||
|
A: Only for code that implements requirements. Internal helpers don't need TRACES.
|
||||||
|
|
||||||
|
**Q: Can I use TRACES on multiple related functions?**
|
||||||
|
A: Yes! Add at the file/module level or on individual functions.
|
||||||
|
|
||||||
|
**Q: What if code doesn't relate to any requirement?**
|
||||||
|
A: Leave it untraced. TRACES are for requirement-driven development.
|
||||||
|
|
||||||
|
**Q: How often should I regenerate reports?**
|
||||||
|
A: Automatically on push (CI/CD). Manually after changes: `bun run traces:markdown`
|
||||||
|
|
||||||
|
**Q: Can I trace to requirements that aren't implemented yet?**
|
||||||
|
A: Yes! TRACES show your implementation plan.
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- [Full Traceability Matrix](docs/traceability.md)
|
||||||
|
- [CI/CD Pipeline Guide](docs/traceability-ci.md)
|
||||||
|
- [Requirements Specification](README.md)
|
||||||
|
- [Extraction Script](scripts/README.md#extract-tracests)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Quick Start:**
|
||||||
|
1. Add `// TRACES: UR-XXX` to new code
|
||||||
|
2. Run `bun run traces:markdown`
|
||||||
|
3. Check `docs/traceability.md`
|
||||||
|
4. Submit PR - workflow validates automatically!
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -18,12 +19,16 @@
|
|||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
|
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
"android:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
"android:check": "./scripts/check-android.sh",
|
"android:check": "./scripts/check-android.sh",
|
||||||
"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": {
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": ">=1.0.0 <5.0.0",
|
||||||
"webdriverio": "^9.5.0"
|
"webdriverio": "^9.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,42 @@ 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](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
||||||
|
|
||||||
## Utility Scripts
|
## Utility Scripts
|
||||||
|
|
||||||
### `clean.sh`
|
### `clean.sh`
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ echo ""
|
|||||||
# Build type: debug or release (default: debug)
|
# Build type: debug or release (default: debug)
|
||||||
BUILD_TYPE="${1:-debug}"
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
# Step 0: Clear build caches to ensure fresh builds
|
||||||
|
echo "🧹 Clearing build caches..."
|
||||||
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
|
npm install > /dev/null 2>&1
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
./scripts/sync-android-sources.sh
|
./scripts/sync-android-sources.sh
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/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}"
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
#!/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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||||
|
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||||
|
const BASE_DIR = path.resolve(import.meta.dir, "..");
|
||||||
|
|
||||||
|
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||||
|
const 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 = BASE_DIR;
|
||||||
|
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 = BASE_DIR;
|
||||||
|
|
||||||
|
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`
|
||||||
|
);
|
||||||
@@ -31,4 +31,14 @@ for kt_file in "$SOURCE_DIR"/*.kt; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Restore the app module build.gradle.kts (media3 deps + release signing config).
|
||||||
|
# gen/android is regenerated by `tauri android init`, so this tracked template is
|
||||||
|
# the source of truth and must be copied back after any (re)generation.
|
||||||
|
APP_GRADLE_SRC="$PROJECT_ROOT/src-tauri/android/app/build.gradle.kts"
|
||||||
|
APP_GRADLE_DST="$PROJECT_ROOT/src-tauri/gen/android/app/build.gradle.kts"
|
||||||
|
if [ -f "$APP_GRADLE_SRC" ]; then
|
||||||
|
cp "$APP_GRADLE_SRC" "$APP_GRADLE_DST"
|
||||||
|
echo " Copied: app/build.gradle.kts"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun test
|
bun run test
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🦀 Running Rust tests..."
|
echo "🦀 Running Rust tests..."
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun test "$@"
|
bun run test "$@"
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
[target.aarch64-linux-android]
|
|
||||||
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
|
|
||||||
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
[target.armv7-linux-androideabi]
|
|
||||||
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
|
|
||||||
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
[target.i686-linux-android]
|
|
||||||
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
|
|
||||||
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
[target.x86_64-linux-android]
|
|
||||||
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
|
|
||||||
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
[env]
|
|
||||||
# Point to the NDK for the cc crate and other build scripts
|
|
||||||
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
|
|
||||||
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
|
|
||||||
|
|
||||||
# Set CC/CXX for each Android target (cc crate looks for these)
|
|
||||||
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
|
|
||||||
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
|
|
||||||
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
|
|
||||||
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
|
|
||||||
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
|
|
||||||
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
|
|
||||||
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
|
|
||||||
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
|
|
||||||
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
|
|
||||||
|
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "Inflector"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -1745,7 +1751,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core 0.62.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2011,14 +2017,18 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
"specta",
|
||||||
|
"specta-typescript",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
|
"tauri-specta",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rusqlite",
|
"tokio-rusqlite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2795,6 +2805,12 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3933,6 +3949,51 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta"
|
||||||
|
version = "2.0.0-rc.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"paste",
|
||||||
|
"specta-macros",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-macros"
|
||||||
|
version = "2.0.0-rc.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
|
||||||
|
dependencies = [
|
||||||
|
"Inflector",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.112",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-serde"
|
||||||
|
version = "0.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
|
||||||
|
dependencies = [
|
||||||
|
"specta",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "specta-typescript"
|
||||||
|
version = "0.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
|
||||||
|
dependencies = [
|
||||||
|
"specta",
|
||||||
|
"specta-serde",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -4092,7 +4153,7 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
@@ -4149,6 +4210,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"serialize-to-javascript",
|
"serialize-to-javascript",
|
||||||
|
"specta",
|
||||||
"swift-rs",
|
"swift-rs",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-macros",
|
"tauri-macros",
|
||||||
@@ -4337,6 +4399,34 @@ dependencies = [
|
|||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-specta"
|
||||||
|
version = "2.0.0-rc.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"specta",
|
||||||
|
"specta-typescript",
|
||||||
|
"tauri",
|
||||||
|
"tauri-specta-macros",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-specta-macros"
|
||||||
|
version = "2.0.0-rc.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
|
||||||
|
dependencies = [
|
||||||
|
"heck 0.5.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.112",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-utils"
|
name = "tauri-utils"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
@@ -4870,6 +4960,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5143,7 +5239,7 @@ dependencies = [
|
|||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
]
|
]
|
||||||
@@ -5167,7 +5263,7 @@ checksum = "36695906a1b53a3bf5c4289621efedac12b73eeb0b89e7e1a89b517302d5d75c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5223,7 +5319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections",
|
"windows-collections",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-future",
|
"windows-future",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics",
|
||||||
@@ -5235,7 +5331,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5247,21 +5343,8 @@ dependencies = [
|
|||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-core"
|
|
||||||
version = "0.62.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|
||||||
dependencies = [
|
|
||||||
"windows-implement",
|
|
||||||
"windows-interface",
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
"windows-result 0.4.1",
|
|
||||||
"windows-strings 0.5.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5270,7 +5353,7 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
@@ -5315,7 +5398,7 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5328,15 +5411,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-result"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -5346,15 +5420,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-strings"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
@@ -5750,7 +5815,7 @@ dependencies = [
|
|||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ edition = "2021"
|
|||||||
name = "jellytau_lib"
|
name = "jellytau_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
# Keep debug info minimal to reduce target/ size in CI (line numbers in
|
||||||
|
# backtraces are preserved; the bulky full debuginfo is dropped).
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@ rand = "0.8"
|
|||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
@@ -45,6 +51,9 @@ sha2 = "0.10"
|
|||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
|
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||||
|
specta-typescript = "=0.0.9"
|
||||||
|
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
|
||||||
|
|
||||||
# Linux-specific dependencies
|
# Linux-specific dependencies
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release signing: loaded from gen/android/keystore.properties if present.
|
||||||
|
// Falls back to no signing config (debug-signed) when the file is absent.
|
||||||
|
val keystoreProperties = Properties().apply {
|
||||||
|
val propFile = rootProject.file("keystore.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "com.dtourolle.jellytau"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "com.dtourolle.jellytau"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keystoreProperties.getProperty("storeFile")?.let {
|
||||||
|
storeFile = file(it)
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
if (keystoreProperties.getProperty("storeFile") != null) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
|
// Media3 dependencies for audio playback
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-session:1.5.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.5.0")
|
||||||
|
implementation("com.google.guava:guava:33.0.0-android")
|
||||||
|
|
||||||
|
// Media library for VolumeProviderCompat (remote volume control)
|
||||||
|
implementation("androidx.media:media:1.7.0")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* This class handles attaching and detaching the native ExoPlayer SurfaceView
|
||||||
|
* so that it renders video content behind the WebView.
|
||||||
|
*/
|
||||||
|
object VideoOverlayManager {
|
||||||
|
|
||||||
|
private var attachedSurfaceView: SurfaceView? = null
|
||||||
|
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||||
|
private var listenerContentView: ViewGroup? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the video SurfaceView to the Activity's content view.
|
||||||
|
*
|
||||||
|
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
||||||
|
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to attach the surface to
|
||||||
|
*/
|
||||||
|
fun attachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
// Get the SurfaceView from JellyTauPlayer
|
||||||
|
val player = JellyTauPlayer.getInstance()
|
||||||
|
val surfaceView = player.getSurfaceView()
|
||||||
|
|
||||||
|
if (surfaceView == null) {
|
||||||
|
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the root content view
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
|
||||||
|
// Remove from parent if already attached elsewhere
|
||||||
|
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||||
|
|
||||||
|
// Configure layout params to fill the screen
|
||||||
|
val layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to content view at index 0 (behind WebView)
|
||||||
|
contentView.addView(surfaceView, 0, layoutParams)
|
||||||
|
attachedSurfaceView = surfaceView
|
||||||
|
|
||||||
|
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||||
|
// device rotation) so the video is letterboxed to fit instead of being
|
||||||
|
// stretched/cropped by the MATCH_PARENT surface.
|
||||||
|
removeLayoutListener()
|
||||||
|
val listener = android.view.View.OnLayoutChangeListener {
|
||||||
|
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||||
|
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
|
||||||
|
player.fitSurfaceToScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentView.addOnLayoutChangeListener(listener)
|
||||||
|
contentLayoutListener = listener
|
||||||
|
listenerContentView = contentView
|
||||||
|
|
||||||
|
// Fit once now that the surface is attached and the parent is sized.
|
||||||
|
player.fitSurfaceToScreen()
|
||||||
|
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the video SurfaceView from the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to detach the surface from
|
||||||
|
*/
|
||||||
|
fun detachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
removeLayoutListener()
|
||||||
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
contentView.removeView(surfaceView)
|
||||||
|
attachedSurfaceView = null
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a video surface is currently attached.
|
||||||
|
*
|
||||||
|
* @return true if a surface is attached, false otherwise
|
||||||
|
*/
|
||||||
|
fun isVideoSurfaceAttached(): Boolean {
|
||||||
|
return attachedSurfaceView != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLayoutListener() {
|
||||||
|
contentLayoutListener?.let { listener ->
|
||||||
|
listenerContentView?.removeOnLayoutChangeListener(listener)
|
||||||
|
}
|
||||||
|
contentLayoutListener = null
|
||||||
|
listenerContentView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.LruCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory cache for album artwork bitmaps with LRU eviction.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
|
||||||
|
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
|
||||||
|
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
|
||||||
|
* - Graceful error handling for network failures and corrupted images
|
||||||
|
* - Singleton pattern for app-wide access
|
||||||
|
*
|
||||||
|
* Cache lifecycle: In-memory only, cleared on app termination.
|
||||||
|
*/
|
||||||
|
class AlbumArtCache(context: Context) {
|
||||||
|
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
||||||
|
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
|
||||||
|
|
||||||
|
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||||
|
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||||
|
return bitmap.byteCount / 1024 // Size in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get artwork bitmap for the given URL.
|
||||||
|
*
|
||||||
|
* Checks memory cache first, then downloads from network if not cached.
|
||||||
|
* Scaling and error handling are done transparently.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The bitmap, or null if download failed or URL is invalid
|
||||||
|
*/
|
||||||
|
suspend fun getArtwork(url: String): Bitmap? {
|
||||||
|
// Check memory cache first
|
||||||
|
memoryCache.get(url)?.let { return it }
|
||||||
|
|
||||||
|
// Download from network if not cached
|
||||||
|
return downloadAndCache(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download artwork from network and add to cache.
|
||||||
|
*
|
||||||
|
* Runs on IO dispatcher to avoid blocking the main thread.
|
||||||
|
* Automatically scales large images to 512x512 max.
|
||||||
|
* On failure, logs error and returns null.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The cached bitmap, or null if download/decode failed
|
||||||
|
*/
|
||||||
|
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
connection.doInput = true
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.readTimeout = 5000
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val input = connection.inputStream
|
||||||
|
val bitmap = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
connection.disconnect()
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
// Scale down if too large (lock screen doesn't need full resolution)
|
||||||
|
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
|
||||||
|
memoryCache.put(url, scaled)
|
||||||
|
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
|
||||||
|
scaled
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
|
||||||
|
*
|
||||||
|
* @param bitmap The original bitmap
|
||||||
|
* @param maxSize Maximum width or height (e.g., 512)
|
||||||
|
* @return Original bitmap if smaller than maxSize, else scaled version
|
||||||
|
*/
|
||||||
|
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
|
||||||
|
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
|
||||||
|
|
||||||
|
val ratio = minOf(
|
||||||
|
maxSize.toFloat() / bitmap.width,
|
||||||
|
maxSize.toFloat() / bitmap.height
|
||||||
|
)
|
||||||
|
|
||||||
|
val newWidth = (bitmap.width * ratio).toInt()
|
||||||
|
val newHeight = (bitmap.height * ratio).toInt()
|
||||||
|
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached bitmaps from memory.
|
||||||
|
*
|
||||||
|
* Useful for memory-constrained situations or settings reset.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
memoryCache.evictAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: AlbumArtCache? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create singleton instance.
|
||||||
|
*
|
||||||
|
* Thread-safe with double-checked locking pattern.
|
||||||
|
*
|
||||||
|
* @param context Android context for initialization
|
||||||
|
* @return The singleton AlbumArtCache instance
|
||||||
|
*/
|
||||||
|
fun getInstance(context: Context): AlbumArtCache {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: AlbumArtCache(context).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||||
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
||||||
|
|
||||||
// Wrap the ExoPlayer to intercept commands
|
// Wrap the ExoPlayer to intercept commands from Media3 controllers
|
||||||
|
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
|
||||||
|
// session rather than the MediaSessionCompat).
|
||||||
|
//
|
||||||
|
// We do NOT execute on ExoPlayer directly here. Every transport command
|
||||||
|
// is routed to Rust via nativeOnMediaCommand, which is the single decision
|
||||||
|
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
|
||||||
|
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
|
||||||
|
// would double-handle local commands and incorrectly drive the local
|
||||||
|
// player while casting.
|
||||||
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||||
override fun play() {
|
override fun play() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.play()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("play")
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.pause()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("pause")
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToNext() {
|
override fun seekToNext() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToNext()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("next")
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToPrevious() {
|
override fun seekToPrevious() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToPrevious()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("previous")
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekTo(positionMs)
|
|
||||||
// Then notify Rust of seek
|
|
||||||
val positionSeconds = positionMs / 1000.0
|
val positionSeconds = positionMs / 1000.0
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.stop()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("stop")
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
)
|
)
|
||||||
isActive = true
|
isActive = true
|
||||||
|
|
||||||
// Set callback to handle lock screen button presses
|
// Set callback to handle lock screen button presses.
|
||||||
|
//
|
||||||
|
// All transport commands are routed through Rust via nativeOnMediaCommand
|
||||||
|
// rather than directly to ExoPlayer. Rust is the single decision point:
|
||||||
|
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
|
||||||
|
// the command to the remote Jellyfin session. This keeps the lockscreen
|
||||||
|
// working identically for both, and avoids the ExoPlayer-only behaviour
|
||||||
|
// that left remote playback uncontrollable from the lockscreen.
|
||||||
setCallback(object : MediaSessionCompat.Callback() {
|
setCallback(object : MediaSessionCompat.Callback() {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||||
wrappedPlayer?.play()
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||||
wrappedPlayer?.pause()
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||||
wrappedPlayer?.seekToNext()
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||||
wrappedPlayer?.seekToPrevious()
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
wrappedPlayer?.stop()
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
override fun onSeekTo(position: Long) {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
wrappedPlayer?.seekTo(position)
|
val positionSeconds = position / 1000.0
|
||||||
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,9 +252,19 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last-known metadata/state, retained so lightweight position ticks can
|
||||||
|
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
|
||||||
|
// and notification. Kept in sync by updateMediaMetadata().
|
||||||
|
private var lastTitle: String = ""
|
||||||
|
private var lastArtist: String = ""
|
||||||
|
private var lastIsPlaying: Boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the MediaSession metadata and playback state.
|
* Update the MediaSession metadata and playback state, plus the notification.
|
||||||
* This updates both the MediaSession and the notification.
|
*
|
||||||
|
* Call this when the track or play/pause state changes. For frequent position
|
||||||
|
* updates during playback, use [updatePlaybackPosition] instead, which is much
|
||||||
|
* cheaper (no metadata rebuild, no notification rebuild).
|
||||||
*/
|
*/
|
||||||
fun updateMediaMetadata(
|
fun updateMediaMetadata(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
) {
|
) {
|
||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
|
|
||||||
|
lastTitle = title
|
||||||
|
lastArtist = artist
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
|
||||||
// Update MediaSession metadata
|
// Update MediaSession metadata
|
||||||
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
||||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
@@ -280,7 +293,42 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state
|
// Update MediaSession playback state
|
||||||
val stateBuilder = PlaybackStateCompat.Builder()
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
|
|
||||||
|
// Update the notification
|
||||||
|
updateNotification(title, artist, isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only the playback position (and play/pause state) on the MediaSession.
|
||||||
|
*
|
||||||
|
* This is the cheap path used for the periodic (250ms) position ticks: it
|
||||||
|
* refreshes the lockscreen scrubber without rebuilding metadata or the
|
||||||
|
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||||
|
* from the last play/pause and drifts out of sync with actual playback.
|
||||||
|
*
|
||||||
|
* @param position Position in milliseconds
|
||||||
|
* @param isPlaying Whether playback is currently active
|
||||||
|
*/
|
||||||
|
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||||
|
val session = mediaSessionCompat ?: return
|
||||||
|
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
|
// Only rebuild the notification when the play/pause icon actually flips.
|
||||||
|
if (notificationStateChanged) {
|
||||||
|
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a PlaybackStateCompat with the standard transport actions.
|
||||||
|
*
|
||||||
|
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||||
|
* Android does not extrapolate the position past a paused track.
|
||||||
|
*/
|
||||||
|
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||||
|
return PlaybackStateCompat.Builder()
|
||||||
.setActions(
|
.setActions(
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
@@ -292,13 +340,9 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
.setState(
|
.setState(
|
||||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
position,
|
position,
|
||||||
1.0f
|
if (isPlaying) 1.0f else 0.0f
|
||||||
)
|
)
|
||||||
|
.build()
|
||||||
session.setPlaybackState(stateBuilder.build())
|
|
||||||
|
|
||||||
// Update the notification
|
|
||||||
updateNotification(title, artist, isPlaying)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,11 +138,22 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Current media ID being played */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
|
||||||
|
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
|
||||||
|
* end of a transcoded stream), which would otherwise notify the backend
|
||||||
|
* twice and, for example, decrement the sleep-timer episode counter twice.
|
||||||
|
* Reset whenever new media is loaded.
|
||||||
|
*/
|
||||||
|
private var endedNotified = false
|
||||||
|
|
||||||
/** Current media metadata for notification updates */
|
/** Current media metadata for notification updates */
|
||||||
private var currentTitle: String = ""
|
private var currentTitle: String = ""
|
||||||
private var currentArtist: String = ""
|
private var currentArtist: String = ""
|
||||||
private var currentAlbum: String? = null
|
private var currentAlbum: String? = null
|
||||||
private var currentDurationMs: Long = 0
|
private var currentDurationMs: Long = 0
|
||||||
|
private var currentArtworkUrl: String? = null
|
||||||
|
private var currentArtworkBitmap: android.graphics.Bitmap? = null
|
||||||
|
|
||||||
/** Media type enum */
|
/** Media type enum */
|
||||||
enum class MediaType { AUDIO, VIDEO }
|
enum class MediaType { AUDIO, VIDEO }
|
||||||
@@ -150,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** SurfaceView for video playback */
|
/** SurfaceView for video playback */
|
||||||
private var surfaceView: SurfaceView? = null
|
private var surfaceView: SurfaceView? = null
|
||||||
private var surfaceHolder: SurfaceHolder? = null
|
private var surfaceHolder: SurfaceHolder? = null
|
||||||
|
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||||
|
private var videoWidth: Int = 0
|
||||||
|
private var videoHeight: Int = 0
|
||||||
private var currentMediaType: MediaType = MediaType.AUDIO
|
private var currentMediaType: MediaType = MediaType.AUDIO
|
||||||
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
||||||
|
|
||||||
@@ -169,6 +183,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Create ExoPlayer with audio focus handling
|
// Create ExoPlayer with audio focus handling
|
||||||
exoPlayer = ExoPlayer.Builder(appContext)
|
exoPlayer = ExoPlayer.Builder(appContext)
|
||||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||||
|
// Pause when the audio output is removed (wired headphones unplugged or
|
||||||
|
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||||
|
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
|
||||||
|
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
|
||||||
|
// the lockscreen notification in sync automatically.
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Set up player listener
|
// Set up player listener
|
||||||
@@ -200,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Playback completed
|
// Playback completed
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||||
stopPositionUpdates()
|
stopPositionUpdates()
|
||||||
nativeOnPlaybackEnded()
|
// Only notify the backend once per loaded media. ExoPlayer
|
||||||
|
// can re-enter STATE_ENDED, which would double-count things
|
||||||
|
// like the sleep-timer episode counter.
|
||||||
|
if (!endedNotified) {
|
||||||
|
endedNotified = true
|
||||||
|
nativeOnPlaybackEnded()
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Player.STATE_BUFFERING -> {
|
Player.STATE_BUFFERING -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
||||||
@@ -237,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}")
|
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
|
||||||
|
// Apply pixel aspect ratio so anamorphic content isn't distorted
|
||||||
|
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
|
||||||
|
videoHeight = videoSize.height
|
||||||
|
fitSurfaceToScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRenderedFirstFrame() {
|
override fun onRenderedFirstFrame() {
|
||||||
@@ -314,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun load(url: String, mediaId: String) {
|
fun load(url: String, mediaId: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
@@ -544,12 +577,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
|
|
||||||
// Store metadata for notification updates
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
currentArtist = artist ?: ""
|
currentArtist = artist ?: ""
|
||||||
currentAlbum = album
|
currentAlbum = album
|
||||||
currentDurationMs = durationMs
|
currentDurationMs = durationMs
|
||||||
|
currentArtworkUrl = artworkUrl
|
||||||
|
currentArtworkBitmap = null // Reset on new track
|
||||||
|
|
||||||
// Detect media type
|
// Detect media type
|
||||||
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
|
currentMediaType = if (mediaType.equals("video", ignoreCase = true)) {
|
||||||
@@ -666,6 +702,20 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
// Start the foreground service for lockscreen controls
|
// Start the foreground service for lockscreen controls
|
||||||
startPlaybackService()
|
startPlaybackService()
|
||||||
|
|
||||||
|
// Download album art asynchronously (non-blocking)
|
||||||
|
currentArtworkUrl?.let { url ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
val bitmap = AlbumArtCache.getInstance(appContext).getArtwork(url)
|
||||||
|
currentArtworkBitmap = bitmap
|
||||||
|
updatePlaybackServiceNotification(exoPlayer.isPlaying)
|
||||||
|
android.util.Log.d("JellyTauPlayer", "Album art loaded: ${bitmap?.width}x${bitmap?.height}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("JellyTauPlayer", "Failed to load album art", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
if (exoPlayer.isPlaying) {
|
if (exoPlayer.isPlaying) {
|
||||||
val position = exoPlayer.currentPosition / 1000.0
|
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||||
|
val position = positionMs / 1000.0
|
||||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||||
nativeOnPositionUpdate(position, duration)
|
nativeOnPositionUpdate(position, duration)
|
||||||
|
|
||||||
|
// Keep the lockscreen scrubber live. Without this the
|
||||||
|
// MediaSession position only refreshes on play/pause, so the
|
||||||
|
// scrubber freezes mid-track and drifts out of sync.
|
||||||
|
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||||
}
|
}
|
||||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
@@ -824,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize the video surface (for orientation changes).
|
* Resize the video surface (for orientation changes).
|
||||||
|
*
|
||||||
|
* Re-fits the surface to the screen preserving the video's aspect ratio so
|
||||||
|
* nothing is cropped when the device rotates.
|
||||||
*/
|
*/
|
||||||
fun resizeSurface(width: Int, height: Int) {
|
fun resizeSurface(width: Int, height: Int) {
|
||||||
|
fitSurfaceToScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size the video SurfaceView so the video fits entirely inside its parent
|
||||||
|
* (the full-screen content view) while preserving aspect ratio (letterbox/
|
||||||
|
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
||||||
|
* video to the surface bounds, which crops the bottom on rotation.
|
||||||
|
*/
|
||||||
|
fun fitSurfaceToScreen() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
surfaceView?.let { view ->
|
val view = surfaceView ?: return@post
|
||||||
view.layoutParams = view.layoutParams.apply {
|
val parent = view.parent as? ViewGroup
|
||||||
this.width = width
|
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||||
this.height = height
|
val availW = parent?.width?.takeIf { it > 0 }
|
||||||
}
|
?: appContext.resources.displayMetrics.widthPixels
|
||||||
view.requestLayout()
|
val availH = parent?.height?.takeIf { it > 0 }
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
|
?: appContext.resources.displayMetrics.heightPixels
|
||||||
|
|
||||||
|
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
|
||||||
|
return@post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
|
||||||
|
val viewAspect = availW.toFloat() / availH.toFloat()
|
||||||
|
|
||||||
|
val targetW: Int
|
||||||
|
val targetH: Int
|
||||||
|
if (videoAspect > viewAspect) {
|
||||||
|
// Video is wider than the screen → fit width, letterbox top/bottom
|
||||||
|
targetW = availW
|
||||||
|
targetH = (availW / videoAspect).toInt()
|
||||||
|
} else {
|
||||||
|
// Video is taller than the screen → fit height, pillarbox sides
|
||||||
|
targetH = availH
|
||||||
|
targetW = (availH * videoAspect).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val lp = view.layoutParams
|
||||||
|
// FrameLayout child: center the fitted surface within the full-screen parent.
|
||||||
|
if (lp is FrameLayout.LayoutParams) {
|
||||||
|
lp.gravity = android.view.Gravity.CENTER
|
||||||
|
}
|
||||||
|
lp.width = targetW
|
||||||
|
lp.height = targetH
|
||||||
|
view.layoutParams = lp
|
||||||
|
view.requestLayout()
|
||||||
|
android.util.Log.d(
|
||||||
|
"JellyTauPlayer",
|
||||||
|
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -10,8 +10,9 @@ use crate::connectivity::ConnectivityMonitor;
|
|||||||
pub use session_verifier::SessionVerifier;
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
/// Server information returned from Jellyfin
|
/// Server information returned from Jellyfin
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[specta(rename = "AuthServerInfo")]
|
||||||
pub struct ServerInfo {
|
pub struct ServerInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@@ -21,7 +22,7 @@ pub struct ServerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User information
|
/// User information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -31,7 +32,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication result
|
/// Authentication result
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AuthResult {
|
pub struct AuthResult {
|
||||||
pub user: User,
|
pub user: User,
|
||||||
@@ -40,7 +41,7 @@ pub struct AuthResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Active session for restoration
|
/// Active session for restoration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
@@ -55,7 +56,7 @@ pub struct Session {
|
|||||||
|
|
||||||
// Jellyfin API response types (PascalCase from server)
|
// Jellyfin API response types (PascalCase from server)
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct PublicSystemInfo {
|
struct PublicSystemInfo {
|
||||||
server_name: String,
|
server_name: String,
|
||||||
@@ -63,7 +64,7 @@ struct PublicSystemInfo {
|
|||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct AuthenticateByNameResponse {
|
struct AuthenticateByNameResponse {
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
@@ -71,7 +72,7 @@ struct AuthenticateByNameResponse {
|
|||||||
server_id: String,
|
server_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct JellyfinUser {
|
struct JellyfinUser {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -102,12 +103,18 @@ impl AuthManager {
|
|||||||
self.connectivity_monitor = Some(monitor);
|
self.connectivity_monitor = Some(monitor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize and validate server URL
|
/// Normalize and validate server URL.
|
||||||
pub fn normalize_url(url: &str) -> String {
|
/// Enforces HTTPS — plain HTTP is rejected for security.
|
||||||
|
pub fn normalize_url(url: &str) -> Result<String, String> {
|
||||||
let mut normalized = url.trim().to_string();
|
let mut normalized = url.trim().to_string();
|
||||||
|
|
||||||
|
// Reject plain HTTP — all connections must use HTTPS
|
||||||
|
if normalized.starts_with("http://") {
|
||||||
|
return Err("HTTP connections are not allowed. Please use HTTPS (e.g., https://your-server.com).".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Add https:// if no protocol specified
|
// Add https:// if no protocol specified
|
||||||
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
if !normalized.starts_with("https://") {
|
||||||
normalized = format!("https://{}", normalized);
|
normalized = format!("https://{}", normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +123,12 @@ impl AuthManager {
|
|||||||
normalized.pop();
|
normalized.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized
|
Ok(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to server and get server info
|
/// Connect to server and get server info
|
||||||
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
let normalized_url = Self::normalize_url(server_url);
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
@@ -165,7 +172,7 @@ impl AuthManager {
|
|||||||
password: &str,
|
password: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<AuthResult, String> {
|
) -> Result<AuthResult, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Authenticating user: {}", username);
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
@@ -227,7 +234,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<User, String> {
|
) -> Result<User, String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Users/{}", url, user_id);
|
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||||
|
|
||||||
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||||
@@ -290,7 +297,7 @@ impl AuthManager {
|
|||||||
access_token: &str,
|
access_token: &str,
|
||||||
device_id: &str,
|
device_id: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let url = Self::normalize_url(server_url);
|
let url = Self::normalize_url(server_url)?;
|
||||||
let endpoint = format!("{}/Sessions/Logout", url);
|
let endpoint = format!("{}/Sessions/Logout", url);
|
||||||
|
|
||||||
log::info!("[AuthManager] Logging out");
|
log::info!("[AuthManager] Logging out");
|
||||||
@@ -337,43 +344,43 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Test URL normalization - adds https:// when missing
|
/// Test URL normalization - adds https:// when missing
|
||||||
///
|
|
||||||
/// Ensures that URLs without protocol are normalized to https://
|
|
||||||
/// This prevents "builder error" when constructing HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_adds_https() {
|
fn test_normalize_url_adds_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com"),
|
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("192.168.1.100:8096"),
|
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
|
||||||
"https://192.168.1.100:8096"
|
"https://192.168.1.100:8096"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - preserves existing protocol
|
/// Test URL normalization - preserves existing https
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_preserves_protocol() {
|
fn test_normalize_url_preserves_https() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com"),
|
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
AuthManager::normalize_url("http://localhost:8096"),
|
|
||||||
"http://localhost:8096"
|
/// Test URL normalization - rejects HTTP
|
||||||
);
|
#[test]
|
||||||
|
fn test_normalize_url_rejects_http() {
|
||||||
|
assert!(AuthManager::normalize_url("http://localhost:8096").is_err());
|
||||||
|
assert!(AuthManager::normalize_url("http://jellyfin.example.com").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - removes trailing slash
|
/// Test URL normalization - removes trailing slash
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_removes_trailing_slash() {
|
fn test_normalize_url_removes_trailing_slash() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("https://jellyfin.example.com/"),
|
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url("jellyfin.example.com/"),
|
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,25 +389,20 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_trims_whitespace() {
|
fn test_normalize_url_trims_whitespace() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" jellyfin.example.com "),
|
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
|
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
|
||||||
"https://jellyfin.example.com"
|
"https://jellyfin.example.com"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test URL normalization - complex case
|
/// Test URL normalization - real world case
|
||||||
///
|
|
||||||
/// This is the bug that caused the login issue: user enters URL
|
|
||||||
/// without protocol, it gets stored in DB, then fails when building
|
|
||||||
/// HTTP requests.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_normalize_url_real_world_case() {
|
fn test_normalize_url_real_world_case() {
|
||||||
// User input: "jellyfin.tourolle.paris"
|
|
||||||
let input = "jellyfin.tourolle.paris";
|
let input = "jellyfin.tourolle.paris";
|
||||||
let normalized = AuthManager::normalize_url(input);
|
let normalized = AuthManager::normalize_url(input).unwrap();
|
||||||
|
|
||||||
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||||
assert!(normalized.starts_with("https://"));
|
assert!(normalized.starts_with("https://"));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerif
|
|||||||
/// Initialize the auth manager (call on app startup)
|
/// Initialize the auth manager (call on app startup)
|
||||||
/// Restores session from storage if available
|
/// Restores session from storage if available
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_initialize(
|
pub async fn auth_initialize(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
database: State<'_, crate::commands::DatabaseWrapper>,
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
||||||
@@ -39,7 +40,7 @@ pub async fn auth_initialize(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create session object from active session with normalized URL
|
// Create session object from active session with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: active_session.user_id,
|
user_id: active_session.user_id,
|
||||||
@@ -61,6 +62,7 @@ pub async fn auth_initialize(
|
|||||||
|
|
||||||
/// Connect to a Jellyfin server and get server info
|
/// Connect to a Jellyfin server and get server info
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_connect_to_server(
|
pub async fn auth_connect_to_server(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
@@ -70,6 +72,7 @@ pub async fn auth_connect_to_server(
|
|||||||
|
|
||||||
/// Login with username and password
|
/// Login with username and password
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_login(
|
pub async fn auth_login(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -80,7 +83,7 @@ pub async fn auth_login(
|
|||||||
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||||
|
|
||||||
// Create session from auth result with normalized URL
|
// Create session from auth result with normalized URL
|
||||||
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
|
|
||||||
let session = Session {
|
let session = Session {
|
||||||
user_id: result.user.id.clone(),
|
user_id: result.user.id.clone(),
|
||||||
@@ -100,6 +103,7 @@ pub async fn auth_login(
|
|||||||
|
|
||||||
/// Verify current session
|
/// Verify current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_verify_session(
|
pub async fn auth_verify_session(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -118,6 +122,7 @@ pub async fn auth_verify_session(
|
|||||||
|
|
||||||
/// Logout (clear session and call Jellyfin logout endpoint)
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_logout(
|
pub async fn auth_logout(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -143,6 +148,7 @@ pub async fn auth_logout(
|
|||||||
|
|
||||||
/// Get current session
|
/// Get current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_get_session(
|
pub async fn auth_get_session(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<Option<Session>, String> {
|
) -> Result<Option<Session>, String> {
|
||||||
@@ -151,15 +157,19 @@ pub async fn auth_get_session(
|
|||||||
|
|
||||||
/// Set current session (for restoration from storage)
|
/// Set current session (for restoration from storage)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_set_session(
|
pub async fn auth_set_session(
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Normalize the server URL if session is provided
|
// Normalize the server URL if session is provided
|
||||||
let normalized_session = session.map(|mut s| {
|
let normalized_session = match session {
|
||||||
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
|
Some(mut s) => {
|
||||||
s
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||||
});
|
Some(s)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
auth_manager.0.set_session(normalized_session).await;
|
auth_manager.0.set_session(normalized_session).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,6 +177,7 @@ pub async fn auth_set_session(
|
|||||||
|
|
||||||
/// Start background session verification
|
/// Start background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_start_verification(
|
pub async fn auth_start_verification(
|
||||||
device_id: String,
|
device_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -195,6 +206,7 @@ pub async fn auth_start_verification(
|
|||||||
|
|
||||||
/// Stop background session verification
|
/// Stop background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_stop_verification(
|
pub async fn auth_stop_verification(
|
||||||
session_verifier: State<'_, SessionVerifierWrapper>,
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -209,6 +221,7 @@ pub async fn auth_stop_verification(
|
|||||||
|
|
||||||
/// Re-authenticate with password (when session expired)
|
/// Re-authenticate with password (when session expired)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_reauthenticate(
|
pub async fn auth_reauthenticate(
|
||||||
password: String,
|
password: String,
|
||||||
device_id: String,
|
device_id: String,
|
||||||
@@ -237,3 +250,197 @@ pub async fn auth_reauthenticate(
|
|||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_serialization() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-123".to_string(),
|
||||||
|
username: "john_doe".to_string(),
|
||||||
|
server_id: "server-456".to_string(),
|
||||||
|
server_url: "https://jellyfin.example.com".to_string(),
|
||||||
|
server_name: "My Jellyfin".to_string(),
|
||||||
|
access_token: "token-789-xyz".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should serialize successfully
|
||||||
|
let json = serde_json::to_string(&session);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("user-123"));
|
||||||
|
assert!(serialized.contains("john_doe"));
|
||||||
|
assert!(serialized.contains("server-456"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_deserialization() {
|
||||||
|
let json = r#"{
|
||||||
|
"userId": "user-123",
|
||||||
|
"username": "john_doe",
|
||||||
|
"serverId": "server-456",
|
||||||
|
"serverUrl": "https://jellyfin.example.com",
|
||||||
|
"serverName": "My Jellyfin",
|
||||||
|
"accessToken": "token-789",
|
||||||
|
"verified": true,
|
||||||
|
"needsReauth": false
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let result: Result<Session, _> = serde_json::from_str(json);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let session = result.unwrap();
|
||||||
|
assert_eq!(session.user_id, "user-123");
|
||||||
|
assert_eq!(session.username, "john_doe");
|
||||||
|
assert_eq!(session.server_id, "server-456");
|
||||||
|
assert!(session.verified);
|
||||||
|
assert!(!session.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_roundtrip() {
|
||||||
|
let original = Session {
|
||||||
|
user_id: "user-999".to_string(),
|
||||||
|
username: "alice".to_string(),
|
||||||
|
server_id: "server-111".to_string(),
|
||||||
|
server_url: "https://server.local".to_string(),
|
||||||
|
server_name: "Home Server".to_string(),
|
||||||
|
access_token: "very-long-token-string".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(original.user_id, deserialized.user_id);
|
||||||
|
assert_eq!(original.username, deserialized.username);
|
||||||
|
assert_eq!(original.server_id, deserialized.server_id);
|
||||||
|
assert_eq!(original.server_url, deserialized.server_url);
|
||||||
|
assert_eq!(original.access_token, deserialized.access_token);
|
||||||
|
assert_eq!(original.verified, deserialized.verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_clone() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-clone".to_string(),
|
||||||
|
username: "test_user".to_string(),
|
||||||
|
server_id: "server-clone".to_string(),
|
||||||
|
server_url: "https://clone.example.com".to_string(),
|
||||||
|
server_name: "Clone Server".to_string(),
|
||||||
|
access_token: "clone-token".to_string(),
|
||||||
|
verified: false,
|
||||||
|
needs_reauth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = session.clone();
|
||||||
|
assert_eq!(session.user_id, cloned.user_id);
|
||||||
|
assert_eq!(session.username, cloned.username);
|
||||||
|
assert_eq!(session.verified, cloned.verified);
|
||||||
|
assert_eq!(session.needs_reauth, cloned.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_unverified() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-unverified".to_string(),
|
||||||
|
username: "newuser".to_string(),
|
||||||
|
server_id: "server-new".to_string(),
|
||||||
|
server_url: "https://new.example.com".to_string(),
|
||||||
|
server_name: "New Server".to_string(),
|
||||||
|
access_token: "new-token".to_string(),
|
||||||
|
verified: false,
|
||||||
|
needs_reauth: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
assert!(json.contains("false")); // verified: false
|
||||||
|
assert!(json.contains("true")); // needs_reauth: true
|
||||||
|
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
assert!(!deserialized.verified);
|
||||||
|
assert!(deserialized.needs_reauth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_debug() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-debug".to_string(),
|
||||||
|
username: "debug_user".to_string(),
|
||||||
|
server_id: "server-debug".to_string(),
|
||||||
|
server_url: "https://debug.example.com".to_string(),
|
||||||
|
server_name: "Debug Server".to_string(),
|
||||||
|
access_token: "debug-token".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", session);
|
||||||
|
assert!(debug_str.contains("user-debug"));
|
||||||
|
assert!(debug_str.contains("Session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_manager_wrapper_structure() {
|
||||||
|
// Verify wrapper type exists and has correct structure
|
||||||
|
assert_eq!(std::mem::size_of::<AuthManagerWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_verifier_wrapper_structure() {
|
||||||
|
// Verify wrapper type exists and has correct structure
|
||||||
|
assert_eq!(std::mem::size_of::<SessionVerifierWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_with_special_characters() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "user-special-éñ".to_string(),
|
||||||
|
username: "user@example.com".to_string(),
|
||||||
|
server_id: "server/123".to_string(),
|
||||||
|
server_url: "https://jellyfin.example.com:8096".to_string(),
|
||||||
|
server_name: "My Jellyfin (v10.8.0)".to_string(),
|
||||||
|
access_token: "token+with/special=chars".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(session.username, deserialized.username);
|
||||||
|
assert_eq!(session.server_name, deserialized.server_name);
|
||||||
|
assert_eq!(session.access_token, deserialized.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_field_presence() {
|
||||||
|
let session = Session {
|
||||||
|
user_id: "u1".to_string(),
|
||||||
|
username: "user1".to_string(),
|
||||||
|
server_id: "s1".to_string(),
|
||||||
|
server_url: "url1".to_string(),
|
||||||
|
server_name: "name1".to_string(),
|
||||||
|
access_token: "token1".to_string(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&session).unwrap();
|
||||||
|
|
||||||
|
// Verify camelCase serialization (serde rename_all = "camelCase")
|
||||||
|
assert!(json.contains("userId"));
|
||||||
|
assert!(json.contains("username"));
|
||||||
|
assert!(json.contains("serverId"));
|
||||||
|
assert!(json.contains("serverUrl"));
|
||||||
|
assert!(json.contains("serverName"));
|
||||||
|
assert!(json.contains("accessToken"));
|
||||||
|
assert!(json.contains("verified"));
|
||||||
|
assert!(json.contains("needsReauth"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMon
|
|||||||
|
|
||||||
/// Check if the server is currently reachable
|
/// Check if the server is currently reachable
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_check_server(
|
pub async fn connectivity_check_server(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -16,6 +17,7 @@ pub async fn connectivity_check_server(
|
|||||||
|
|
||||||
/// Set the server URL and trigger an immediate check
|
/// Set the server URL and trigger an immediate check
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_set_server_url(
|
pub async fn connectivity_set_server_url(
|
||||||
url: String,
|
url: String,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -27,6 +29,7 @@ pub async fn connectivity_set_server_url(
|
|||||||
|
|
||||||
/// Get the current connectivity status
|
/// Get the current connectivity status
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_get_status(
|
pub async fn connectivity_get_status(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<ConnectivityStatus, String> {
|
) -> Result<ConnectivityStatus, String> {
|
||||||
@@ -36,6 +39,7 @@ pub async fn connectivity_get_status(
|
|||||||
|
|
||||||
/// Start monitoring connectivity with adaptive polling
|
/// Start monitoring connectivity with adaptive polling
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_start_monitoring(
|
pub async fn connectivity_start_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -46,6 +50,7 @@ pub async fn connectivity_start_monitoring(
|
|||||||
|
|
||||||
/// Stop monitoring connectivity
|
/// Stop monitoring connectivity
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_stop_monitoring(
|
pub async fn connectivity_stop_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -56,6 +61,7 @@ pub async fn connectivity_stop_monitoring(
|
|||||||
|
|
||||||
/// Mark the server as reachable (called after successful API calls)
|
/// Mark the server as reachable (called after successful API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_reachable(
|
pub async fn connectivity_mark_reachable(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -66,6 +72,7 @@ pub async fn connectivity_mark_reachable(
|
|||||||
|
|
||||||
/// Mark the server as unreachable (called after failed API calls)
|
/// Mark the server as unreachable (called after failed API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_unreachable(
|
pub async fn connectivity_mark_unreachable(
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -74,3 +81,18 @@ pub async fn connectivity_mark_unreachable(
|
|||||||
monitor.mark_unreachable(error).await;
|
monitor.mark_unreachable(error).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connectivity_monitor_wrapper_structure() {
|
||||||
|
// Test that wrapper can be created and holds Arc
|
||||||
|
// We can't instantiate ConnectivityMonitor directly in tests
|
||||||
|
// due to its dependencies, so we just test the wrapper type structure
|
||||||
|
|
||||||
|
// This verifies the wrapper type exists and can hold Arc<Mutex>
|
||||||
|
assert_eq!(std::mem::size_of::<ConnectivityMonitorWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::utils::conversions::{
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "3:45" or "12:09"
|
/// Formatted string like "3:45" or "12:09"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds(seconds: f64) -> String {
|
pub fn format_time_seconds(seconds: f64) -> String {
|
||||||
format_time(seconds)
|
format_time(seconds)
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ pub fn format_time_seconds(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "1:23:45" or "3:45"
|
/// Formatted string like "1:23:45" or "3:45"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds_long(seconds: f64) -> String {
|
pub fn format_time_seconds_long(seconds: f64) -> String {
|
||||||
format_time_long(seconds)
|
format_time_long(seconds)
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ pub fn format_time_seconds_long(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Time in seconds
|
/// Time in seconds
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
||||||
ticks_to_seconds(ticks)
|
ticks_to_seconds(ticks)
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Progress as percentage (0.0 to 100.0)
|
/// Progress as percentage (0.0 to 100.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
||||||
calculate_progress(position, duration)
|
calculate_progress(position, duration)
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,57 @@ pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Normalized volume (0.0 to 1.0)
|
/// Normalized volume (0.0 to 1.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
||||||
percent_to_volume(percent)
|
percent_to_volume(percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_time_seconds() {
|
||||||
|
assert_eq!(format_time_seconds(0.0), "0:00");
|
||||||
|
assert_eq!(format_time_seconds(59.0), "0:59");
|
||||||
|
assert_eq!(format_time_seconds(60.0), "1:00");
|
||||||
|
assert_eq!(format_time_seconds(125.0), "2:05");
|
||||||
|
assert_eq!(format_time_seconds(3661.0), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_time_seconds_long() {
|
||||||
|
assert_eq!(format_time_seconds_long(0.0), "0:00");
|
||||||
|
assert_eq!(format_time_seconds_long(59.0), "0:59");
|
||||||
|
assert_eq!(format_time_seconds_long(3599.0), "59:59");
|
||||||
|
assert_eq!(format_time_seconds_long(3600.0), "1:00:00");
|
||||||
|
assert_eq!(format_time_seconds_long(3661.0), "1:01:01");
|
||||||
|
assert_eq!(format_time_seconds_long(7384.0), "2:03:04");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_ticks_to_seconds() {
|
||||||
|
assert_eq!(convert_ticks_to_seconds(0), 0.0);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(10_000_000), 1.0);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(5_000_000), 0.5);
|
||||||
|
assert_eq!(convert_ticks_to_seconds(60_000_000), 6.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calc_progress() {
|
||||||
|
assert_eq!(calc_progress(0.0, 100.0), 0.0);
|
||||||
|
assert_eq!(calc_progress(50.0, 100.0), 50.0);
|
||||||
|
assert_eq!(calc_progress(100.0, 100.0), 100.0);
|
||||||
|
assert_eq!(calc_progress(25.0, 0.0), 0.0); // Invalid duration
|
||||||
|
assert_eq!(calc_progress(150.0, 100.0), 100.0); // Clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_convert_percent_to_volume() {
|
||||||
|
assert_eq!(convert_percent_to_volume(0.0), 0.0);
|
||||||
|
assert_eq!(convert_percent_to_volume(50.0), 0.5);
|
||||||
|
assert_eq!(convert_percent_to_volume(100.0), 1.0);
|
||||||
|
assert_eq!(convert_percent_to_volume(150.0), 1.0); // Clamped
|
||||||
|
assert_eq!(convert_percent_to_volume(-10.0), 0.0); // Clamped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! 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]
|
||||||
|
#[specta::specta]
|
||||||
|
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]
|
||||||
|
#[specta::specta]
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! Pinning commands - protect an item's cached metadata from cache clearing.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::DatabaseWrapper;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// Pin an item's metadata (protects from cache clear)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE items SET is_pinned = 1 WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpin an item's metadata
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"UPDATE items SET is_pinned = 0 WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an item is pinned
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT COALESCE(is_pinned, 0) FROM items WHERE id = ?",
|
||||||
|
vec![QueryParam::String(item_id)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let is_pinned: i32 = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(is_pinned == 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Smart-cache statistics/config and album recommendation commands.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use log::info;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// SmartCache statistics
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SmartCacheStats {
|
||||||
|
pub total_size: u64,
|
||||||
|
pub storage_limit: u64,
|
||||||
|
pub available_space: u64,
|
||||||
|
pub items_count: i64,
|
||||||
|
pub config: crate::download::cache::CacheConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SmartCache statistics
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_smart_cache_stats(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<SmartCacheStats, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone cache to avoid holding lock across async operations
|
||||||
|
let cache = {
|
||||||
|
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_size = cache
|
||||||
|
.get_total_download_size_async(&db_service, &user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let config = cache
|
||||||
|
.get_config()
|
||||||
|
.ok_or_else(|| "Failed to get cache config".to_string())?;
|
||||||
|
|
||||||
|
let storage_limit = config.storage_limit;
|
||||||
|
let available_space = storage_limit.saturating_sub(total_size);
|
||||||
|
|
||||||
|
// Get item count
|
||||||
|
let count_query = Query::with_params(
|
||||||
|
"SELECT COUNT(*) FROM downloads WHERE user_id = ? AND status = 'completed'",
|
||||||
|
vec![QueryParam::String(user_id)],
|
||||||
|
);
|
||||||
|
let items_count: i64 = db_service
|
||||||
|
.query_one(count_query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(SmartCacheStats {
|
||||||
|
total_size,
|
||||||
|
storage_limit,
|
||||||
|
available_space,
|
||||||
|
items_count,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update SmartCache configuration
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn update_smart_cache_config(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
config: crate::download::cache::CacheConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
cache.update_config(config);
|
||||||
|
info!("Updated SmartCache configuration");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get SmartCache configuration
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_smart_cache_config(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
) -> Result<crate::download::cache::CacheConfig, String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
cache
|
||||||
|
.get_config()
|
||||||
|
.ok_or_else(|| "Failed to get cache config".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album recommendation info
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AlbumRecommendation {
|
||||||
|
pub album_id: String,
|
||||||
|
pub album_name: String,
|
||||||
|
pub tracks_played: usize,
|
||||||
|
pub total_tracks: usize,
|
||||||
|
pub should_download: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album affinity status info
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AlbumAffinityStatus {
|
||||||
|
pub album_id: String,
|
||||||
|
pub unique_tracks_played: usize,
|
||||||
|
pub threshold: usize,
|
||||||
|
pub threshold_reached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get album recommendations based on play history
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_album_recommendations(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Vec<AlbumRecommendation>, String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone cache to avoid holding lock across async operations
|
||||||
|
let cache = {
|
||||||
|
let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
guard.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all albums that user has played tracks from
|
||||||
|
let query = Query::with_params(
|
||||||
|
"SELECT DISTINCT i.album_id, a.name
|
||||||
|
FROM user_data ud
|
||||||
|
JOIN items i ON ud.item_id = i.id
|
||||||
|
JOIN items a ON i.album_id = a.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
AND ud.play_count > 0
|
||||||
|
AND i.item_type = 'Audio'
|
||||||
|
AND i.album_id IS NOT NULL",
|
||||||
|
vec![QueryParam::String(user_id.clone())],
|
||||||
|
);
|
||||||
|
|
||||||
|
let albums: Vec<(String, String)> = db_service
|
||||||
|
.query_many(query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut recommendations = Vec::new();
|
||||||
|
|
||||||
|
for (album_id, album_name) in albums {
|
||||||
|
// Check if should cache
|
||||||
|
let should_download = cache.should_cache_album(&album_id).unwrap_or(false);
|
||||||
|
|
||||||
|
// Get track counts
|
||||||
|
let tracks_query = Query::with_params(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(ud.id) as played
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN user_data ud ON i.id = ud.item_id AND ud.user_id = ?
|
||||||
|
WHERE i.album_id = ? AND i.item_type = 'Audio'",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id.clone()),
|
||||||
|
QueryParam::String(album_id.clone()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let (total_tracks, tracks_played): (i64, i64) = db_service
|
||||||
|
.query_one(tracks_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.await
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
|
||||||
|
if tracks_played > 0 {
|
||||||
|
recommendations.push(AlbumRecommendation {
|
||||||
|
album_id,
|
||||||
|
album_name,
|
||||||
|
tracks_played: tracks_played as usize,
|
||||||
|
total_tracks: total_tracks as usize,
|
||||||
|
should_download,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by tracks played (descending)
|
||||||
|
recommendations.sort_by(|a, b| b.tracks_played.cmp(&a.tracks_played));
|
||||||
|
|
||||||
|
Ok(recommendations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get album affinity status for all tracked albums
|
||||||
|
/// This shows the SmartCache's internal play history and threshold status
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn get_album_affinity_status(
|
||||||
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
) -> Result<Vec<AlbumAffinityStatus>, String> {
|
||||||
|
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Get the threshold from config
|
||||||
|
let threshold = cache
|
||||||
|
.get_config()
|
||||||
|
.map(|c| c.album_affinity_threshold)
|
||||||
|
.unwrap_or(3);
|
||||||
|
|
||||||
|
// Get all tracked albums with their play counts
|
||||||
|
let play_history = cache.get_album_play_history();
|
||||||
|
|
||||||
|
let mut statuses: Vec<AlbumAffinityStatus> = play_history
|
||||||
|
.into_iter()
|
||||||
|
.map(|(album_id, unique_tracks_played)| {
|
||||||
|
let threshold_reached = unique_tracks_played >= threshold;
|
||||||
|
AlbumAffinityStatus {
|
||||||
|
album_id,
|
||||||
|
unique_tracks_played,
|
||||||
|
threshold,
|
||||||
|
threshold_reached,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by play count (descending)
|
||||||
|
statuses.sort_by(|a, b| b.unique_tracks_played.cmp(&a.unique_tracks_played));
|
||||||
|
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
|
// 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;
|
||||||
pub mod playback_reporting;
|
pub mod playback_reporting;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod playlist;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
@@ -14,12 +19,14 @@ 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::*;
|
||||||
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
|
||||||
pub use playback_reporting::*;
|
pub use playback_reporting::*;
|
||||||
pub use player::*;
|
pub use player::*;
|
||||||
|
pub use playlist::*;
|
||||||
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
|
||||||
pub use sessions::*;
|
pub use sessions::*;
|
||||||
pub use storage::*;
|
pub use storage::*;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tauri::State;
|
|||||||
use super::DatabaseWrapper;
|
use super::DatabaseWrapper;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OfflineItem {
|
pub struct OfflineItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -22,6 +22,7 @@ pub struct OfflineItem {
|
|||||||
|
|
||||||
/// Check if an item is available offline
|
/// Check if an item is available offline
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_is_available(
|
pub async fn offline_is_available(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -46,6 +47,7 @@ pub async fn offline_is_available(
|
|||||||
|
|
||||||
/// Get all offline items for a user
|
/// Get all offline items for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_get_items(
|
pub async fn offline_get_items(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -84,6 +86,7 @@ pub async fn offline_get_items(
|
|||||||
|
|
||||||
/// Search offline items
|
/// Search offline items
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_search(
|
pub async fn offline_search(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -130,6 +133,7 @@ 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::*;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
|
|||||||
|
|
||||||
/// Get the current playback mode
|
/// Get the current playback mode
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_get_current(
|
pub fn playback_mode_get_current(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<PlaybackMode, String> {
|
) -> Result<PlaybackMode, String> {
|
||||||
@@ -16,6 +17,7 @@ pub fn playback_mode_get_current(
|
|||||||
|
|
||||||
/// Set the playback mode (internal/testing use)
|
/// Set the playback mode (internal/testing use)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_set(
|
pub fn playback_mode_set(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
mode: PlaybackMode,
|
mode: PlaybackMode,
|
||||||
@@ -26,6 +28,7 @@ pub fn playback_mode_set(
|
|||||||
|
|
||||||
/// Check if currently transferring between playback modes
|
/// Check if currently transferring between playback modes
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_is_transferring(
|
pub fn playback_mode_is_transferring(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -34,15 +37,34 @@ pub fn playback_mode_is_transferring(
|
|||||||
|
|
||||||
/// Transfer playback from local device to a remote Jellyfin session
|
/// Transfer playback from local device to a remote Jellyfin session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_remote(
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
position: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||||
session_id
|
session_id,
|
||||||
|
position
|
||||||
);
|
);
|
||||||
manager.0.transfer_to_remote(session_id).await
|
manager.0.transfer_to_remote(session_id, position).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the transferring flag on the playback mode manager.
|
||||||
|
///
|
||||||
|
/// Used by the frontend remote->local flow to mark the whole two-step sequence
|
||||||
|
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
||||||
|
/// casting back to the remote session it's leaving. Always pair `true` with a
|
||||||
|
/// later `false` (including on error) so the flag can't stick.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playback_mode_set_transferring(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
transferring: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.0.set_transferring(transferring);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from remote session back to local device
|
/// Transfer playback from remote session back to local device
|
||||||
@@ -51,6 +73,7 @@ pub async fn playback_mode_transfer_to_remote(
|
|||||||
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
||||||
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_local(
|
pub async fn playback_mode_transfer_to_local(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
current_item_id: String,
|
current_item_id: String,
|
||||||
@@ -69,6 +92,7 @@ pub async fn playback_mode_transfer_to_local(
|
|||||||
|
|
||||||
/// Get remote session status (for polling position/duration)
|
/// Get remote session status (for polling position/duration)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_get_remote_status(
|
pub async fn playback_mode_get_remote_status(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
player: State<'_, crate::commands::PlayerStateWrapper>,
|
player: State<'_, crate::commands::PlayerStateWrapper>,
|
||||||
@@ -119,7 +143,7 @@ pub async fn playback_mode_get_remote_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remote session status for UI updates
|
/// Remote session status for UI updates
|
||||||
#[derive(serde::Serialize)]
|
#[derive(specta::Type, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RemoteSessionStatus {
|
pub struct RemoteSessionStatus {
|
||||||
pub position: f64,
|
pub position: f64,
|
||||||
@@ -127,3 +151,132 @@ pub struct RemoteSessionStatus {
|
|||||||
pub is_playing: bool,
|
pub is_playing: bool,
|
||||||
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_serialization() {
|
||||||
|
// Test Local playback mode
|
||||||
|
let local_mode = PlaybackMode::Local;
|
||||||
|
let json = serde_json::to_string(&local_mode);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
|
||||||
|
// Test Remote playback mode
|
||||||
|
let remote_mode = PlaybackMode::Remote {
|
||||||
|
session_id: "session-123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&remote_mode);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_serialization() {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: 123.45,
|
||||||
|
duration: Some(600.0),
|
||||||
|
is_playing: true,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should serialize successfully
|
||||||
|
let json = serde_json::to_string(&status);
|
||||||
|
assert!(json.is_ok());
|
||||||
|
|
||||||
|
let serialized = json.unwrap();
|
||||||
|
assert!(serialized.contains("123.45"));
|
||||||
|
assert!(serialized.contains("600"));
|
||||||
|
assert!(serialized.contains("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_with_no_duration() {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: 0.0,
|
||||||
|
duration: None,
|
||||||
|
is_playing: false,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains("null") || json.contains("\"duration\":null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remote_session_status_various_positions() {
|
||||||
|
let positions = vec![0.0, 30.5, 100.0, 3600.0];
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
|
let status = RemoteSessionStatus {
|
||||||
|
position: pos,
|
||||||
|
duration: Some(7200.0),
|
||||||
|
is_playing: true,
|
||||||
|
now_playing_item: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&status).unwrap();
|
||||||
|
assert!(json.contains(&pos.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_mode_deserialization_from_frontend() {
|
||||||
|
// Test what frontend sends for Idle mode
|
||||||
|
let idle_json = r#"{"type":"idle"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(idle_json).expect("Failed to deserialize idle");
|
||||||
|
assert_eq!(mode, PlaybackMode::Idle);
|
||||||
|
|
||||||
|
// Test what frontend sends for Local mode
|
||||||
|
let local_json = r#"{"type":"local"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(local_json).expect("Failed to deserialize local");
|
||||||
|
assert_eq!(mode, PlaybackMode::Local);
|
||||||
|
|
||||||
|
// Test what frontend sends for Remote mode
|
||||||
|
let remote_json = r#"{"type":"remote","session_id":"session-123"}"#;
|
||||||
|
let mode: PlaybackMode = serde_json::from_str(remote_json).expect("Failed to deserialize remote");
|
||||||
|
match mode {
|
||||||
|
PlaybackMode::Remote { session_id } => assert_eq!(session_id, "session-123"),
|
||||||
|
_ => panic!("Expected Remote mode"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_play_tracks_context_deserialization() {
|
||||||
|
use crate::commands::PlayTracksContext;
|
||||||
|
|
||||||
|
// Test Search context (the recently fixed issue)
|
||||||
|
let search_json = r#"{"type":"search","searchQuery":"test query"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(search_json)
|
||||||
|
.expect("Failed to deserialize search context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Search { search_query } => {
|
||||||
|
assert_eq!(search_query, "test query");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Search context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Playlist context
|
||||||
|
let playlist_json = r#"{"type":"playlist","playlistId":"pl-123","playlistName":"My Playlist"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(playlist_json)
|
||||||
|
.expect("Failed to deserialize playlist context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Playlist { playlist_id, playlist_name } => {
|
||||||
|
assert_eq!(playlist_id, "pl-123");
|
||||||
|
assert_eq!(playlist_name, "My Playlist");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Playlist context"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Custom context
|
||||||
|
let custom_json = r#"{"type":"custom","label":"Custom Queue"}"#;
|
||||||
|
let context: PlayTracksContext = serde_json::from_str(custom_json)
|
||||||
|
.expect("Failed to deserialize custom context");
|
||||||
|
match context {
|
||||||
|
PlayTracksContext::Custom { label } => {
|
||||||
|
assert_eq!(label, Some("Custom Queue".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Custom context"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>
|
|||||||
|
|
||||||
/// Initialize playback reporter (called after login)
|
/// Initialize playback reporter (called after login)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_init(
|
pub async fn playback_reporter_init(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -66,6 +67,7 @@ pub async fn playback_reporter_init(
|
|||||||
|
|
||||||
/// Destroy playback reporter (called on logout)
|
/// Destroy playback reporter (called on logout)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_destroy(
|
pub async fn playback_reporter_destroy(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -76,6 +78,7 @@ pub async fn playback_reporter_destroy(
|
|||||||
|
|
||||||
/// Report playback start
|
/// Report playback start
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_start(
|
pub async fn playback_report_start(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -110,6 +113,7 @@ pub async fn playback_report_start(
|
|||||||
|
|
||||||
/// Report playback progress
|
/// Report playback progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_progress(
|
pub async fn playback_report_progress(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -138,6 +142,7 @@ pub async fn playback_report_progress(
|
|||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_stopped(
|
pub async fn playback_report_stopped(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -164,6 +169,7 @@ pub async fn playback_report_stopped(
|
|||||||
|
|
||||||
/// Mark item as played
|
/// Mark item as played
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mark_played(
|
pub async fn playback_mark_played(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -182,3 +188,216 @@ pub async fn playback_mark_played(
|
|||||||
|
|
||||||
reporter_instance.report(operation, is_online).await
|
reporter_instance.report(operation, is_online).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_start_creation() {
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-123".to_string(),
|
||||||
|
position_ticks: 15_000_000,
|
||||||
|
context: Some(PlaybackContext {
|
||||||
|
context_type: "series".to_string(),
|
||||||
|
context_id: Some("series-456".to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify enum variant can be created and pattern matched
|
||||||
|
if let PlaybackOperation::Start { item_id, position_ticks, context } = operation {
|
||||||
|
assert_eq!(item_id, "item-123");
|
||||||
|
assert_eq!(position_ticks, 15_000_000);
|
||||||
|
assert!(context.is_some());
|
||||||
|
let ctx = context.unwrap();
|
||||||
|
assert_eq!(ctx.context_type, "series");
|
||||||
|
assert_eq!(ctx.context_id, Some("series-456".to_string()));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Start variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_start_without_context() {
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-789".to_string(),
|
||||||
|
position_ticks: 5_000_000,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Start { item_id, context, .. } = operation {
|
||||||
|
assert_eq!(item_id, "item-789");
|
||||||
|
assert!(context.is_none());
|
||||||
|
} else {
|
||||||
|
panic!("Expected Start variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_progress_creation() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-999".to_string(),
|
||||||
|
position_ticks: 30_000_000,
|
||||||
|
is_paused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Progress { item_id, position_ticks, is_paused } = operation {
|
||||||
|
assert_eq!(item_id, "item-999");
|
||||||
|
assert_eq!(position_ticks, 30_000_000);
|
||||||
|
assert!(is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Progress variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_progress_playing() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-555".to_string(),
|
||||||
|
position_ticks: 45_000_000,
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Progress { is_paused, .. } = operation {
|
||||||
|
assert!(!is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Progress variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_stopped_creation() {
|
||||||
|
let operation = PlaybackOperation::Stopped {
|
||||||
|
item_id: "item-111".to_string(),
|
||||||
|
position_ticks: 120_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::Stopped { item_id, position_ticks } = operation {
|
||||||
|
assert_eq!(item_id, "item-111");
|
||||||
|
assert_eq!(position_ticks, 120_000_000);
|
||||||
|
} else {
|
||||||
|
panic!("Expected Stopped variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_mark_played_creation() {
|
||||||
|
let operation = PlaybackOperation::MarkPlayed {
|
||||||
|
item_id: "item-222".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let PlaybackOperation::MarkPlayed { item_id } = operation {
|
||||||
|
assert_eq!(item_id, "item-222");
|
||||||
|
} else {
|
||||||
|
panic!("Expected MarkPlayed variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_with_series() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "series".to_string(),
|
||||||
|
context_id: Some("series-789".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(context.context_type, "series");
|
||||||
|
assert_eq!(context.context_id, Some("series-789".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_without_id() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "folder".to_string(),
|
||||||
|
context_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(context.context_type, "folder");
|
||||||
|
assert!(context.context_id.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_context_clone() {
|
||||||
|
let context = PlaybackContext {
|
||||||
|
context_type: "container".to_string(),
|
||||||
|
context_id: Some("container-123".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = context.clone();
|
||||||
|
assert_eq!(cloned.context_type, "container");
|
||||||
|
assert_eq!(cloned.context_id, Some("container-123".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_seconds_to_ticks_conversion() {
|
||||||
|
assert_eq!(seconds_to_ticks(0.0), 0);
|
||||||
|
assert_eq!(seconds_to_ticks(1.0), 10_000_000);
|
||||||
|
assert_eq!(seconds_to_ticks(1.5), 15_000_000);
|
||||||
|
assert_eq!(seconds_to_ticks(120.0), 1_200_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_reporter_wrapper_structure() {
|
||||||
|
// Verify wrapper type can hold Arc<TokioMutex<Option<T>>>
|
||||||
|
assert_eq!(std::mem::size_of::<PlaybackReporterWrapper>() > 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_debug_trait() {
|
||||||
|
// Verify Debug trait is implemented for operations
|
||||||
|
let operation = PlaybackOperation::Start {
|
||||||
|
item_id: "item-1".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug_str = format!("{:?}", operation);
|
||||||
|
assert!(debug_str.contains("Start"));
|
||||||
|
assert!(debug_str.contains("item-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_clone() {
|
||||||
|
let operation = PlaybackOperation::Progress {
|
||||||
|
item_id: "item-clone".to_string(),
|
||||||
|
position_ticks: 50_000_000,
|
||||||
|
is_paused: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cloned = operation.clone();
|
||||||
|
if let PlaybackOperation::Progress { item_id, is_paused, .. } = cloned {
|
||||||
|
assert_eq!(item_id, "item-clone");
|
||||||
|
assert!(is_paused);
|
||||||
|
} else {
|
||||||
|
panic!("Clone failed to preserve variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playback_operation_all_variants() {
|
||||||
|
// Test that all operation variants can be created and matched
|
||||||
|
let start_op = PlaybackOperation::Start {
|
||||||
|
item_id: "i1".to_string(),
|
||||||
|
position_ticks: 0,
|
||||||
|
context: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(start_op, PlaybackOperation::Start { .. }));
|
||||||
|
|
||||||
|
let progress_op = PlaybackOperation::Progress {
|
||||||
|
item_id: "i2".to_string(),
|
||||||
|
position_ticks: 100,
|
||||||
|
is_paused: false,
|
||||||
|
};
|
||||||
|
assert!(matches!(progress_op, PlaybackOperation::Progress { .. }));
|
||||||
|
|
||||||
|
let stopped_op = PlaybackOperation::Stopped {
|
||||||
|
item_id: "i3".to_string(),
|
||||||
|
position_ticks: 200,
|
||||||
|
};
|
||||||
|
assert!(matches!(stopped_op, PlaybackOperation::Stopped { .. }));
|
||||||
|
|
||||||
|
let played_op = PlaybackOperation::MarkPlayed {
|
||||||
|
item_id: "i4".to_string(),
|
||||||
|
};
|
||||||
|
assert!(matches!(played_op, PlaybackOperation::MarkPlayed { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! Queue manipulation commands (add / remove / move / skip).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
check_for_local_download, create_media_item, get_player_status, get_queue_status,
|
||||||
|
DatabaseWrapper, PlayItemRequest, PlayerStateWrapper, PlayerStatus, QueueStatus,
|
||||||
|
};
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::player::{MediaItem, MediaSource, MediaType};
|
||||||
|
use crate::repository::types::{ImageOptions, ImageType};
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
/// Request to add items to queue
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddToQueueRequest {
|
||||||
|
pub items: Vec<PlayItemRequest>,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to add a track by ID - backend fetches metadata
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddTrackByIdRequest {
|
||||||
|
pub track_id: String,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to add multiple tracks by IDs - backend fetches metadata
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddTracksByIdsRequest {
|
||||||
|
pub track_ids: Vec<String>,
|
||||||
|
pub position: String, // "next" or "end"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_to_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
request: AddToQueueRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create media items first (without holding any locks during await)
|
||||||
|
let mut items: Vec<MediaItem> = Vec::new();
|
||||||
|
for req in request.items {
|
||||||
|
items.push(create_media_item(req, Some(&db)).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now add to queue
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(items, position);
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_remove_from_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.remove(index);
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_move_in_queue(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
from_index: usize,
|
||||||
|
to_index: usize,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if !queue_lock.move_item(from_index, to_index) {
|
||||||
|
return Err("Invalid indices for move operation".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = QueueStatus {
|
||||||
|
items: queue_lock.items().to_vec(),
|
||||||
|
current_index: queue_lock.current_index(),
|
||||||
|
shuffle: queue_lock.is_shuffle(),
|
||||||
|
repeat: queue_lock.repeat_mode(),
|
||||||
|
has_next: queue_lock.has_next(),
|
||||||
|
has_previous: queue_lock.has_previous(),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a track to queue by ID - backend fetches metadata and constructs URLs
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_track_by_id(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository_manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
request: AddTrackByIdRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
info!("player_add_track_by_id called: track_id={}, position={}",
|
||||||
|
request.track_id, request.position);
|
||||||
|
|
||||||
|
// Get repository (hybrid - supports offline/online)
|
||||||
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
|
// Fetch track metadata via repository
|
||||||
|
info!("Fetching metadata for track {} via repository", request.track_id);
|
||||||
|
let track = repository.get_item(&request.track_id).await
|
||||||
|
.map_err(|e| format!("Failed to fetch track metadata: {}", e))?;
|
||||||
|
|
||||||
|
// Check for local download first
|
||||||
|
let local_path = check_for_local_download(&db, &request.track_id).await?;
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
info!("Using local download for track {}", request.track_id);
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(track.id.clone()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get stream URL from repository (works online/offline)
|
||||||
|
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||||
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id: track.id.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
|
let media_item = MediaItem {
|
||||||
|
id: track.id.clone(),
|
||||||
|
title: track.name.clone(),
|
||||||
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
|
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
|
album: track.album_name.clone(),
|
||||||
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
|
album_id: track.album_id.clone(),
|
||||||
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
|
playlist_id: None,
|
||||||
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
|
track.album_id.as_ref().map(|album_id| {
|
||||||
|
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||||
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source,
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to queue at specified position
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(vec![media_item], position);
|
||||||
|
|
||||||
|
let result = get_queue_status(&controller);
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
info!("Successfully added track {} to queue", request.track_id);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_add_tracks_by_ids(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
repository_manager: State<'_, RepositoryManagerWrapper>,
|
||||||
|
repository_handle: String,
|
||||||
|
request: AddTracksByIdsRequest,
|
||||||
|
) -> Result<QueueStatus, String> {
|
||||||
|
use crate::player::queue::AddPosition;
|
||||||
|
|
||||||
|
info!("player_add_tracks_by_ids called: {} tracks, position={}",
|
||||||
|
request.track_ids.len(), request.position);
|
||||||
|
|
||||||
|
// Get repository (hybrid - supports offline/online)
|
||||||
|
let repository = repository_manager.0.get(&repository_handle)
|
||||||
|
.ok_or("Repository not found - user may need to log in")?;
|
||||||
|
|
||||||
|
// Fetch metadata and build MediaItems for all tracks
|
||||||
|
let mut media_items = Vec::new();
|
||||||
|
for track_id in &request.track_ids {
|
||||||
|
info!("Fetching metadata for track {} via repository", track_id);
|
||||||
|
let track = repository.get_item(track_id).await
|
||||||
|
.map_err(|e| format!("Failed to fetch track metadata for {}: {}", track_id, e))?;
|
||||||
|
|
||||||
|
// Check for local download first
|
||||||
|
let local_path = check_for_local_download(&db, track_id).await?;
|
||||||
|
|
||||||
|
let source = if let Some(path) = local_path {
|
||||||
|
info!("Using local download for track {}", track_id);
|
||||||
|
MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(track.id.clone()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get stream URL from repository (works online/offline)
|
||||||
|
let stream_url = repository.get_audio_stream_url(&track.id).await
|
||||||
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
||||||
|
|
||||||
|
MediaSource::Remote {
|
||||||
|
stream_url,
|
||||||
|
jellyfin_item_id: track.id.clone(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build MediaItem with artwork URL from repository and frontend-compatible fields
|
||||||
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
||||||
|
let media_item = MediaItem {
|
||||||
|
id: track.id.clone(),
|
||||||
|
title: track.name.clone(),
|
||||||
|
name: Some(track.name.clone()), // Frontend compatibility
|
||||||
|
artist: track.album_artist.clone().or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
|
||||||
|
album: track.album_name.clone(),
|
||||||
|
album_name: track.album_name.clone(), // Frontend compatibility
|
||||||
|
album_id: track.album_id.clone(),
|
||||||
|
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||||
|
artists: track.artists.clone(), // Fallback artist info
|
||||||
|
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||||
|
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||||
|
playlist_id: None,
|
||||||
|
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||||
|
artwork_url: primary_image_tag_for_url.and_then(|tag| {
|
||||||
|
track.album_id.as_ref().map(|album_id| {
|
||||||
|
repository.get_image_url(album_id, ImageType::Primary, Some(ImageOptions {
|
||||||
|
max_width: Some(300),
|
||||||
|
tag: Some(tag),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source,
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
media_items.push(media_item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to queue at specified position
|
||||||
|
let position = match request.position.as_str() {
|
||||||
|
"next" => AddPosition::Next,
|
||||||
|
_ => AddPosition::End,
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
queue_lock.add(media_items, position);
|
||||||
|
|
||||||
|
let result = get_queue_status(&controller);
|
||||||
|
drop(queue_lock);
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
info!("Successfully added {} tracks to queue", request.track_ids.len());
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_skip_to(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<PlayerStatus, String> {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
|
// Prefer downloads that completed since the queue was built
|
||||||
|
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
|
||||||
|
log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip to the index and get the item to play
|
||||||
|
let item = {
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
queue_lock.skip_to(index).cloned().ok_or("Invalid index")?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Play the item without modifying the queue
|
||||||
|
controller.load_and_play(&item).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Emit queue changed event
|
||||||
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
|
Ok(get_player_status(&controller))
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! Remote Jellyfin session control commands (casting to another device).
|
||||||
|
//!
|
||||||
|
//! These thin command adapters forward control actions to the active Jellyfin
|
||||||
|
//! session via the player's configured `JellyfinClient`.
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::PlayerStateWrapper;
|
||||||
|
use crate::jellyfin::client::LmsSyncGroup;
|
||||||
|
|
||||||
|
/// Play items on a remote Jellyfin session (casting)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_play_on_session(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
item_ids: Vec<String>,
|
||||||
|
start_index: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Playing {} items on session {} (start index: {})", item_ids.len(), session_id, start_index);
|
||||||
|
log::info!("[RemoteSession] Item IDs: {:?}", item_ids);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
log::info!("[RemoteSession] Jellyfin client IS configured, calling play_on_session");
|
||||||
|
client.play_on_session(session_id, item_ids, start_index, None).await?;
|
||||||
|
log::info!("[RemoteSession] Successfully started playback on remote session");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
log::error!("[RemoteSession] Jellyfin client is NOT configured! User needs to log out/in or restart app");
|
||||||
|
Err("Jellyfin client not configured - please restart the app or log out and log back in".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a playback command to a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_send_command(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
command: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Sending command '{}' to session {}", command, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.send_session_command(session_id, &command).await?;
|
||||||
|
log::info!("[RemoteSession] Command sent successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_seek(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Seeking to {} ticks on session {}", position_ticks, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_seek(session_id, position_ticks).await?;
|
||||||
|
log::info!("[RemoteSession] Seek successful");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set volume on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_set_volume(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
volume: i32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Setting volume to {} on session {}", volume, session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_set_volume(session_id, volume).await?;
|
||||||
|
log::info!("[RemoteSession] Volume set successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle mute on a remote session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn remote_session_toggle_mute(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[RemoteSession] Toggling mute on session {}", session_id);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.session_toggle_mute(session_id).await?;
|
||||||
|
log::info!("[RemoteSession] Mute toggled successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JellyLMS multi-room sync groups (fuse / unfuse LMS zones) --------------
|
||||||
|
//
|
||||||
|
// The frontend addresses LMS players by MAC address, which it derives from a
|
||||||
|
// session's device id (`lms-{mac}`). These commands forward to the JellyLMS
|
||||||
|
// plugin REST API via the configured JellyfinClient.
|
||||||
|
|
||||||
|
/// List current LMS sync groups.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_get_sync_groups(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<Vec<LmsSyncGroup>, String> {
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_get_sync_groups().await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuse LMS zones into a sync group. `master_mac` keeps playing and the
|
||||||
|
/// `slave_macs` zones join it in sync.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_create_sync_group(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
master_mac: String,
|
||||||
|
slave_macs: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[LmsSync] Fusing zones: master={}, slaves={:?}", master_mac, slave_macs);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_create_sync_group(&master_mac, slave_macs).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a single LMS zone from its sync group (decouple one player).
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_unsync_player(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
mac: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[LmsSync] Decoupling zone {}", mac);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_unsync_player(&mac).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dissolve an entire LMS sync group, identified by its master's MAC.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn lms_dissolve_sync_group(
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
master_mac: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[LmsSync] Dissolving group with master {}", master_mac);
|
||||||
|
|
||||||
|
let client_opt = {
|
||||||
|
let controller = player.0.lock().await;
|
||||||
|
controller.jellyfin_client().lock().map_err(|e| e.to_string())?.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(client) = client_opt {
|
||||||
|
client.lms_dissolve_sync_group(&master_mac).await
|
||||||
|
} else {
|
||||||
|
Err("Jellyfin client not configured".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Media session state commands.
|
||||||
|
//!
|
||||||
|
//! Read and dismiss the current media session (the Now Playing surface backing
|
||||||
|
//! lockscreen/notification controls).
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use super::{MediaSessionManagerWrapper, PlayerStateWrapper};
|
||||||
|
use crate::player::PlayerStatusEvent;
|
||||||
|
|
||||||
|
/// Get the current media session state
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_get_session(
|
||||||
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
) -> Result<crate::player::session::MediaSessionType, String> {
|
||||||
|
let manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Ok(manager.current().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss the current media session (returns to Idle)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn player_dismiss_session(
|
||||||
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
log::info!("[Session] Dismissing current media session");
|
||||||
|
|
||||||
|
// Dismiss the session
|
||||||
|
{
|
||||||
|
let mut manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit session changed event
|
||||||
|
if let Some(emitter) = player.0.lock().await.event_emitter() {
|
||||||
|
let manager = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
emitter.emit(PlayerStatusEvent::SessionChanged {
|
||||||
|
session: manager.current().clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||