Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8f35084b | ||
|
|
4634ed595c | ||
|
|
6836ce79c8 | ||
|
|
2811e1b7ca | ||
|
|
1836615dc0 | ||
|
|
62874564ff | ||
|
|
17a35573a0 | ||
|
|
dcf08f30bc | ||
|
|
674c8e5cd0 | ||
|
|
45aa029916 | ||
|
|
3faa595b76 | ||
|
|
7fb866a583 | ||
|
|
0c3ed74fe1 | ||
|
|
a5b6266a6d | ||
|
|
a816c84f8c | ||
|
|
87762c03b6 | ||
|
|
975936b902 | ||
|
|
5ba9e0e958 | ||
|
|
ccc9dca924 | ||
|
|
60bb6c72d1 | ||
|
|
37cc9b424d | ||
|
|
2daee7ec2d | ||
|
|
01c6423154 | ||
|
|
d01c2aab9f | ||
|
|
14e9d7e03a | ||
|
|
76c78e2edc | ||
|
|
6146d70bc5 | ||
|
|
ea342d76e3 | ||
|
|
7d07b38d50 | ||
|
|
dc1f516da7 | ||
|
|
731966246f | ||
|
|
57ff364f54 | ||
|
|
ada3ed64ab | ||
|
|
55f1b85f12 | ||
|
|
26286ac6e7 | ||
|
|
144abb2f8b | ||
|
|
642ec17069 | ||
|
|
e560258a4b | ||
|
|
d1e5ba4c5d | ||
|
|
8ddad497bf | ||
|
|
46b9d328ab | ||
|
|
dd5d648d21 | ||
|
|
6866f03c55 | ||
|
|
0738ef10ec | ||
|
|
d5bca41c60 | ||
|
|
c959c07ab4 | ||
|
|
d6aa74fc85 | ||
|
|
09780103a7 | ||
|
|
3a9c126dfe | ||
|
|
c5be9eb18c | ||
|
|
e8e37649fa | ||
|
|
07f3bf04ca | ||
|
|
9594e963bc | ||
|
|
179c51a6fe | ||
|
|
59270e8a4f | ||
|
|
b8363aa993 | ||
|
|
410203a5b8 | ||
|
|
d199162dc6 | ||
|
|
27f5c435de | ||
|
|
cbd5435e26 | ||
|
|
a5e065daed | ||
|
|
8f1c4bc9da | ||
|
|
dbcaa1a1a5 | ||
|
|
e664bf4620 | ||
|
|
57f8a54dac | ||
|
|
e3797f32ca | ||
|
|
6d1c618a3a | ||
|
|
544ea43a84 | ||
|
|
e560543181 | ||
|
|
cfddc1edea |
@@ -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
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.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
|
||||||
|
/dist
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# WebdriverIO E2E tests
|
||||||
|
e2e/logs/
|
||||||
|
e2e/screenshots/
|
||||||
|
wdio-*.log
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
.vitest
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.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"]
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# JellyTau
|
||||||
|
|
||||||
|
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project uses [bun](https://bun.sh) as its package manager.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Activate the Rust environment (fish shell)
|
||||||
|
source "$HOME/.cargo/env.fish"
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Run in development
|
||||||
|
bun run tauri dev
|
||||||
|
|
||||||
|
# Type-check the frontend
|
||||||
|
bun run check
|
||||||
|
|
||||||
|
# Build for Linux
|
||||||
|
bun run tauri build
|
||||||
|
|
||||||
|
# Build for Android
|
||||||
|
bun run tauri android build
|
||||||
|
```
|
||||||
|
|
||||||
|
For the full set of build, test, and Android helper scripts, see
|
||||||
|
[scripts/README.md](scripts/README.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Topic | Location |
|
||||||
|
|-------|----------|
|
||||||
|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||||
|
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||||
|
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
||||||
|
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||||
|
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||||
|
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||||
|
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[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).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -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
|
||||||
@@ -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!
|
||||||
@@ -0,0 +1,907 @@
|
|||||||
|
# JellyTau UX Flows & Screen Transitions
|
||||||
|
|
||||||
|
This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core Navigation Structure
|
||||||
|
|
||||||
|
### 1.1 Navigation System
|
||||||
|
|
||||||
|
JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.
|
||||||
|
|
||||||
|
**Bottom Navigation Bar (All Platforms - DR-045, UR-039):**
|
||||||
|
|
||||||
|
The bottom navigation bar is the primary navigation and is **always visible** on all platforms (mobile and desktop) except when:
|
||||||
|
- Full-screen video player is active
|
||||||
|
- User is on the login screen
|
||||||
|
|
||||||
|
**Bottom Nav Structure:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [Home] [Library] [Search] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- **Home** → `/` (home page with carousels and featured content)
|
||||||
|
- **Library** → `/library` (library selector showing all libraries)
|
||||||
|
- **Search** → `/search` (dedicated search page)
|
||||||
|
|
||||||
|
**Note:** Available on both mobile and desktop for consistent navigation access.
|
||||||
|
|
||||||
|
**Header Navigation (Desktop):**
|
||||||
|
|
||||||
|
On desktop (md breakpoint and above), the header contains:
|
||||||
|
- Logo (links to `/library`)
|
||||||
|
- Navigation links: Home, Library, Downloads, Settings
|
||||||
|
- Search bar (inline)
|
||||||
|
- User menu: Username, Downloads icon, Logout button
|
||||||
|
|
||||||
|
**Mobile Navigation:**
|
||||||
|
|
||||||
|
On mobile, the header contains:
|
||||||
|
- Logo
|
||||||
|
- Three-dot overflow menu button (Android-style)
|
||||||
|
- Overflow menu includes:
|
||||||
|
- Downloads
|
||||||
|
- Settings
|
||||||
|
- Sign out
|
||||||
|
|
||||||
|
**Access Points Summary:**
|
||||||
|
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||||
|
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Initial App Launch Flow
|
||||||
|
|
||||||
|
### 2.1 First-Time Launch
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
|
||||||
|
CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
|
||||||
|
CheckAuth -->|Yes| AutoLogin[Auto-login]
|
||||||
|
|
||||||
|
LoginScreen --> EnterURL[Enter Server URL]
|
||||||
|
EnterURL --> EnterCreds[Enter Username/Password]
|
||||||
|
EnterCreds --> LoginSuccess{Success?}
|
||||||
|
LoginSuccess -->|No| LoginError[Show Error]
|
||||||
|
LoginError --> EnterCreds
|
||||||
|
LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
|
||||||
|
|
||||||
|
AutoLogin --> TokenValid{Token Valid?}
|
||||||
|
TokenValid -->|No| LoginScreen
|
||||||
|
TokenValid -->|Yes| HomePage
|
||||||
|
|
||||||
|
StoreToken --> HomePage[Home Page<br/>/]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Screens:**
|
||||||
|
1. **Login Screen** (`/login`)
|
||||||
|
- Server URL input
|
||||||
|
- Username input
|
||||||
|
- Password input
|
||||||
|
- "Remember me" checkbox (default: on)
|
||||||
|
- Login button
|
||||||
|
- No header, no bottom nav
|
||||||
|
|
||||||
|
2. **Home Page** (`/`)
|
||||||
|
- Default landing page after successful login
|
||||||
|
- Shows featured content, carousels, continue watching
|
||||||
|
- No MiniPlayer visible (nothing playing yet)
|
||||||
|
- Bottom nav: Home tab active
|
||||||
|
- Header with navigation links
|
||||||
|
|
||||||
|
### 2.2 Subsequent Launches
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Launch[App Launch] --> LoadAuth[Load Stored Token]
|
||||||
|
LoadAuth --> Validate{Token Valid?}
|
||||||
|
Validate -->|Yes| RestoreState[Restore Last Screen]
|
||||||
|
Validate -->|No| LoginScreen[Login Screen<br/>/login]
|
||||||
|
|
||||||
|
RestoreState --> CheckPlayer{Was Player<br/>Active?}
|
||||||
|
CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
|
||||||
|
CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
|
||||||
|
|
||||||
|
ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
|
||||||
|
HideMiniPlayer --> HomePage[Home Page<br/>/]
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Restoration:**
|
||||||
|
- Last viewed screen (route) is restored (defaults to `/` if none)
|
||||||
|
- If audio was playing, MiniPlayer appears at bottom
|
||||||
|
- Playback state is NOT automatically resumed (user must press play)
|
||||||
|
- Queue is restored if it existed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Audio Playback Flows
|
||||||
|
|
||||||
|
### 3.1 Starting Audio Playback
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Start[User Action] --> Action{Action Type?}
|
||||||
|
|
||||||
|
Action -->|Click Track| TrackList[TrackList Component]
|
||||||
|
Action -->|Click Album| AlbumDetail[Album Detail Page]
|
||||||
|
Action -->|Click Play on Album| AlbumPlay[Play Album Button]
|
||||||
|
|
||||||
|
TrackList --> PlayTrack[Play Single Track]
|
||||||
|
PlayTrack --> QueueAll[Queue All Filtered Tracks]
|
||||||
|
|
||||||
|
AlbumPlay --> PlayAlbum[Play All Album Tracks]
|
||||||
|
PlayAlbum --> QueueAlbum[Queue Album Tracks]
|
||||||
|
|
||||||
|
QueueAll --> InvokePlay[invoke player_play_queue]
|
||||||
|
QueueAlbum --> InvokePlay
|
||||||
|
|
||||||
|
InvokePlay --> PlayerStarts[Player State: Playing]
|
||||||
|
PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
|
||||||
|
|
||||||
|
MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Entry Points for Audio Playback:**
|
||||||
|
1. **TrackList** (`/library/music/tracks`, `/library/music/albums/[id]`)
|
||||||
|
- Click track number → Play track + queue all visible tracks
|
||||||
|
- Clicking track #3 in an album → Play track 3, queue tracks 1-10
|
||||||
|
|
||||||
|
2. **Album Card** (grid views)
|
||||||
|
- Click album → Navigate to album detail
|
||||||
|
- Play button on card → Play album immediately
|
||||||
|
|
||||||
|
3. **Search Results**
|
||||||
|
- Click track → Play track + queue search results
|
||||||
|
- Click album → Navigate to album detail
|
||||||
|
|
||||||
|
**MiniPlayer Behavior:**
|
||||||
|
- Slides up from bottom with animation (300ms)
|
||||||
|
- Height: 64px on mobile, 80px on desktop
|
||||||
|
- Shows: artwork, title, artist, play/pause, next, favorite
|
||||||
|
- Stays visible on ALL screens (except video player)
|
||||||
|
- Click anywhere on MiniPlayer → Navigate to full player
|
||||||
|
|
||||||
|
**Track Highlighting:**
|
||||||
|
When audio is playing, the currently playing track is visually highlighted in track lists and album pages:
|
||||||
|
- Subtle blue background tint
|
||||||
|
- Left border accent in Jellyfin blue
|
||||||
|
- Title text colored in Jellyfin blue
|
||||||
|
- Desktop: Animated pulsing dots indicator next to title
|
||||||
|
- Mobile: Play arrow (▶) inline with title
|
||||||
|
- Highlight updates automatically when skipping to next/previous track
|
||||||
|
|
||||||
|
### 3.2 MiniPlayer → Full Player Transition
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Mini[MiniPlayer Visible] --> UserClick{User Action}
|
||||||
|
|
||||||
|
UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
|
||||||
|
UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
|
||||||
|
|
||||||
|
NavFullPlayer --> FullPlayer[Full Audio Player Screen]
|
||||||
|
SwipeGesture --> FullPlayer
|
||||||
|
|
||||||
|
FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
|
||||||
|
|
||||||
|
ShowControls --> MiniHidden[MiniPlayer Hidden]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full Player Screen** (`/player/[id]`)
|
||||||
|
- **Header:** Song title, artist (clickable links to artist/album pages)
|
||||||
|
- **Artwork:** Large album art (centered, dominant)
|
||||||
|
- **Progress:** Seek bar with current time / total duration
|
||||||
|
- **Controls:** Previous, Play/Pause, Next (large touch targets)
|
||||||
|
- **Secondary Controls:** Shuffle, Repeat mode, Queue, Favorite
|
||||||
|
- **Volume:** Volume slider
|
||||||
|
- **Bottom Nav:** Still visible (can navigate away while playing)
|
||||||
|
- **Back button:** Returns to previous screen, MiniPlayer reappears
|
||||||
|
|
||||||
|
### 3.3 Full Player → Back to Browsing
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
FullPlayer[Full Player Screen] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Back Button / Close| HistoryBack[window.history.back]
|
||||||
|
UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
|
||||||
|
|
||||||
|
HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
|
||||||
|
NavOther --> NewScreen[Navigate to New Screen]
|
||||||
|
|
||||||
|
PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
|
||||||
|
NewScreen --> MiniReappears
|
||||||
|
|
||||||
|
MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation Behavior:**
|
||||||
|
- **Back Button:** Uses browser history (`window.history.back()`) to return to the previous page
|
||||||
|
- **Expected behavior:** Returns user to the screen they were on before opening full player
|
||||||
|
- **Example:** User browsing album → clicks track → full player opens → clicks back → returns to album
|
||||||
|
|
||||||
|
**Key UX Principles:**
|
||||||
|
- **Playback Never Stops:** Navigating away from player does NOT stop playback
|
||||||
|
- **MiniPlayer Persistence:** MiniPlayer visible on ALL screens (except video/login)
|
||||||
|
- **Queue Preserved:** Current queue remains intact
|
||||||
|
- **State Restoration:** Returning to full player shows same state (position, volume, etc.)
|
||||||
|
- **Natural Navigation:** Back button behaves as expected (returns to previous page, not just closes modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Video Playback Flows
|
||||||
|
|
||||||
|
### 4.1 Starting Video Playback
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Start[User Action] --> Action{Action Type?}
|
||||||
|
|
||||||
|
Action -->|Click Movie| MovieDetail[Movie Detail Page]
|
||||||
|
Action -->|Click Episode| EpisodeClick[Episode Click]
|
||||||
|
Action -->|Click Play Button| PlayButton[Play Button]
|
||||||
|
|
||||||
|
MovieDetail --> PlayMovie[Play Movie Button]
|
||||||
|
EpisodeClick --> PlayEpisode[Play Episode]
|
||||||
|
|
||||||
|
PlayMovie --> CheckResume{Resume<br/>Position?}
|
||||||
|
PlayEpisode --> CheckResume
|
||||||
|
|
||||||
|
CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
|
||||||
|
CheckResume -->|No| DirectPlay[Start from Beginning]
|
||||||
|
|
||||||
|
ShowDialog --> UserChoice{User Choice}
|
||||||
|
UserChoice -->|Resume| ResumePlay[Start at Saved Position]
|
||||||
|
UserChoice -->|Start Over| DirectPlay
|
||||||
|
|
||||||
|
ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
|
||||||
|
DirectPlay --> FullscreenVideo
|
||||||
|
|
||||||
|
FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resume Dialog:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Continue Watching? │
|
||||||
|
│ │
|
||||||
|
│ [Movie Title] │
|
||||||
|
│ Resume from 12:34 / 1:45:00 │
|
||||||
|
│ │
|
||||||
|
│ [Start from Beginning] [Resume] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Video Player Screen (IR-003, IR-004, UR-003)
|
||||||
|
|
||||||
|
**Initial State (First 3 seconds):**
|
||||||
|
- Controls visible overlay
|
||||||
|
- Top bar: Back button, title
|
||||||
|
- Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
|
||||||
|
- Center: Large play/pause button
|
||||||
|
|
||||||
|
**After 3 Seconds (Idle):**
|
||||||
|
- All controls fade out (500ms animation)
|
||||||
|
- Fullscreen video only
|
||||||
|
- System UI hidden (status bar, nav bar)
|
||||||
|
|
||||||
|
**User Interaction:**
|
||||||
|
- **Tap screen:** Controls reappear for 3 seconds
|
||||||
|
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
|
||||||
|
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
|
||||||
|
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
|
||||||
|
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
|
||||||
|
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
|
||||||
|
- **Keyboard space/K:** Toggle play/pause
|
||||||
|
- **Keyboard F:** Toggle fullscreen
|
||||||
|
- **Pinch:** Zoom (planned)
|
||||||
|
|
||||||
|
### 4.3 Exiting Video Player
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
VideoPlaying[Video Playing] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Back Button| StopVideo[Stop Playback]
|
||||||
|
UserAction -->|Home Button| Background[App to Background]
|
||||||
|
UserAction -->|Video Ends| VideoEnd[Playback Ended]
|
||||||
|
|
||||||
|
StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
|
||||||
|
VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
|
||||||
|
Background --> PauseVideo[Pause Video]
|
||||||
|
|
||||||
|
SaveProgress --> ExitFullscreen[Exit Fullscreen]
|
||||||
|
SaveComplete --> AutoNext{Next Episode<br/>Available?}
|
||||||
|
|
||||||
|
AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
|
||||||
|
AutoNext -->|No| ExitFullscreen
|
||||||
|
|
||||||
|
ShowCountdown --> UserCancel{User Cancels?}
|
||||||
|
UserCancel -->|Yes| ExitFullscreen
|
||||||
|
UserCancel -->|No, timeout| PlayNext[Play Next Episode]
|
||||||
|
|
||||||
|
ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
|
||||||
|
|
||||||
|
PlayNext --> VideoPlaying
|
||||||
|
|
||||||
|
PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-Next Overlay:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [Episode Thumbnail] │
|
||||||
|
│ │
|
||||||
|
│ Next: S01E02 - Episode Title │
|
||||||
|
│ Starting in 5 seconds... │
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Play Now] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Music Library Navigation Flows
|
||||||
|
|
||||||
|
### 5.1 Music Category Landing Page
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
|
||||||
|
|
||||||
|
ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
|
||||||
|
|
||||||
|
MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
|
||||||
|
|
||||||
|
ShowCategories --> UserClick{User Clicks Category}
|
||||||
|
|
||||||
|
UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
|
||||||
|
UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
|
||||||
|
UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
|
||||||
|
UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
|
||||||
|
UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Category Cards:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ 🎵 │ │ 👤 │ │ 💿 │ │
|
||||||
|
│ │Track│ │Artist│ │Album│ │
|
||||||
|
│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │ 📝 │ │ 🎭 │ │
|
||||||
|
│ │List │ │Genre│ │
|
||||||
|
│ └──────┘ └──────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Albums View Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||||
|
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||||
|
|
||||||
|
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||||
|
|
||||||
|
ShowAlbum --> TrackAction{User Action}
|
||||||
|
|
||||||
|
TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
|
||||||
|
TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
|
||||||
|
TrackAction -->|Download Album| DownloadFlow[Download Flow]
|
||||||
|
TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Album Detail Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [←] [♡] [⬇] │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Album Artwork │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Album Title │
|
||||||
|
│ Artist Name (clickable) │
|
||||||
|
│ 2024 • 12 tracks • 45:23 │
|
||||||
|
│ │
|
||||||
|
│ [▶ Play] [🔀 Shuffle] │
|
||||||
|
│ │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ 1 Track Title 3:45 │
|
||||||
|
│ 2 Track Title 4:12 │
|
||||||
|
│ 3 Track Title 3:28 │
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Artist Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
|
||||||
|
|
||||||
|
ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
|
||||||
|
|
||||||
|
ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
|
||||||
|
|
||||||
|
ShowContent --> UserAction{User Action}
|
||||||
|
|
||||||
|
UserAction -->|Click Album| AlbumDetail[Album Detail Page]
|
||||||
|
UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
|
||||||
|
UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Search Flow
|
||||||
|
|
||||||
|
### 6.1 Search Page Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||||
|
|
||||||
|
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||||
|
|
||||||
|
SearchPage --> EmptyState{Has Query?}
|
||||||
|
|
||||||
|
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||||
|
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||||
|
|
||||||
|
ShowPrompt --> UserTypes[User Types in Search]
|
||||||
|
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||||
|
LiveSearch --> ShowResults
|
||||||
|
|
||||||
|
ShowResults --> UserClick{User Clicks Result}
|
||||||
|
|
||||||
|
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||||
|
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||||
|
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||||
|
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Search Page Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [🔍 Search...] [✕] │
|
||||||
|
│ │
|
||||||
|
│ Songs ──────────────────────────── │
|
||||||
|
│ ♪ Song Title - Artist 3:45 │
|
||||||
|
│ ♪ Song Title - Artist 4:12 │
|
||||||
|
│ See all (23) │
|
||||||
|
│ │
|
||||||
|
│ Albums ─────────────────────────── │
|
||||||
|
│ [Album Cover] Album Title │
|
||||||
|
│ [Album Cover] Album Title │
|
||||||
|
│ See all (8) │
|
||||||
|
│ │
|
||||||
|
│ Artists ────────────────────────── │
|
||||||
|
│ [Photo] Artist Name │
|
||||||
|
│ See all (5) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Download Flows
|
||||||
|
|
||||||
|
### 7.1 Initiating Downloads
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User on Album/Track Page] --> ClickDownload[Click Download Button]
|
||||||
|
|
||||||
|
ClickDownload --> CheckType{Download Type?}
|
||||||
|
|
||||||
|
CheckType -->|Single Track| DownloadTrack[Download Single File]
|
||||||
|
CheckType -->|Album| DownloadAlbum[Download All Tracks]
|
||||||
|
CheckType -->|Artist| ShowOptions[Show Options Dialog]
|
||||||
|
|
||||||
|
ShowOptions --> UserChoice{User Choice}
|
||||||
|
UserChoice -->|Discography| DownloadAll[Download All Albums]
|
||||||
|
UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
|
||||||
|
|
||||||
|
DownloadTrack --> QueueDownload[Queue in Download Manager]
|
||||||
|
DownloadAlbum --> QueueMultiple[Queue Multiple Files]
|
||||||
|
|
||||||
|
QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
|
||||||
|
QueueMultiple --> ShowProgress
|
||||||
|
|
||||||
|
ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download Button States:**
|
||||||
|
```
|
||||||
|
States:
|
||||||
|
1. [⬇] Available - Gray outline
|
||||||
|
2. [○ 45%] Downloading - Blue ring progress
|
||||||
|
3. [✓] Downloaded - Green checkmark
|
||||||
|
4. [!] Failed - Red with retry option
|
||||||
|
5. [⏸] Paused - Yellow pause icon
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Managing Downloads Page
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User] --> NavChoice{Navigation Path}
|
||||||
|
|
||||||
|
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||||
|
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||||
|
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||||
|
|
||||||
|
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||||
|
HeaderIcon --> DownloadsPage
|
||||||
|
TypeURL --> DownloadsPage
|
||||||
|
|
||||||
|
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||||
|
|
||||||
|
ShowTabs --> ActiveTab{Active Tab}
|
||||||
|
|
||||||
|
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||||
|
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||||
|
|
||||||
|
ShowActive --> UserAction1{User Action}
|
||||||
|
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||||
|
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||||
|
|
||||||
|
ShowCompleted --> UserAction2{User Action}
|
||||||
|
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||||
|
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation to Downloads:**
|
||||||
|
- **Desktop:** Click "Downloads" link in header navigation
|
||||||
|
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||||
|
- **Direct:** Navigate to `/downloads` route
|
||||||
|
|
||||||
|
**Downloads Page Layout:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [←] Downloads │
|
||||||
|
│ │
|
||||||
|
│ [Active (3)] [Completed (12)] │
|
||||||
|
│ │
|
||||||
|
│ ─ Downloading ──────────────────── │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ [████████░░] 80% │
|
||||||
|
│ [⏸ Pause] [✕ Cancel] │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ [██░░░░░░░░] 20% │
|
||||||
|
│ [⏸ Pause] [✕ Cancel] │
|
||||||
|
│ │
|
||||||
|
│ ─ Queued ───────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ Album Cover Album Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ Waiting... │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Settings & Account Flows
|
||||||
|
|
||||||
|
### 8.1 Settings Navigation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
User[User] --> NavChoice{Navigation Path}
|
||||||
|
|
||||||
|
NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
|
||||||
|
NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
|
||||||
|
NavChoice -->|Direct| TypeURL[Navigate to /settings]
|
||||||
|
|
||||||
|
HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
|
||||||
|
OverflowMenu --> SettingsPage
|
||||||
|
TypeURL --> SettingsPage
|
||||||
|
|
||||||
|
SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
|
||||||
|
|
||||||
|
ShowSections --> UserClick{User Clicks Section}
|
||||||
|
|
||||||
|
UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
|
||||||
|
UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
|
||||||
|
UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
|
||||||
|
UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Navigation to Settings:**
|
||||||
|
- **Desktop:** Click "Settings" link in header navigation
|
||||||
|
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||||
|
- **Direct:** Navigate to `/settings` route
|
||||||
|
|
||||||
|
### 8.2 Logout Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
|
||||||
|
|
||||||
|
ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
|
||||||
|
|
||||||
|
ConfirmDialog --> UserConfirm{User Confirms?}
|
||||||
|
|
||||||
|
UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
|
||||||
|
UserConfirm -->|Yes| StopPlayer[Stop Playback]
|
||||||
|
|
||||||
|
StopPlayer --> ClearToken[Delete Token from Keyring]
|
||||||
|
ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
|
||||||
|
|
||||||
|
ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
|
||||||
|
|
||||||
|
NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logout Button Location:**
|
||||||
|
- Always visible in header user menu (logout icon)
|
||||||
|
- Accessible from any authenticated screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Background & Lock Screen Behavior
|
||||||
|
|
||||||
|
### 9.1 Audio Playback in Background (Android)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Playing[Audio Playing] --> Background{User Action}
|
||||||
|
|
||||||
|
Background -->|Home Button| AppBackground[App to Background]
|
||||||
|
Background -->|Screen Lock| ScreenLock[Screen Locked]
|
||||||
|
|
||||||
|
AppBackground --> ContinuePlay[Playback Continues]
|
||||||
|
ScreenLock --> ContinuePlay
|
||||||
|
|
||||||
|
ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
|
||||||
|
|
||||||
|
ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
|
||||||
|
|
||||||
|
LockScreen --> UserInteract{User Interaction}
|
||||||
|
|
||||||
|
UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
|
||||||
|
UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
|
||||||
|
UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notification Layout (Android):**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ [Artwork] Song Title │
|
||||||
|
│ Artist Name │
|
||||||
|
│ Album Name │
|
||||||
|
│ │
|
||||||
|
│ [⏮] [⏸] [⏭] [✕] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Video Playback in Background
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
VideoPlaying[Video Playing] --> Background{User Action}
|
||||||
|
|
||||||
|
Background -->|Home Button| AutoPause[Automatically Pause]
|
||||||
|
Background -->|Screen Lock| AutoPause
|
||||||
|
|
||||||
|
AutoPause --> SaveProgress[Save Progress]
|
||||||
|
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
|
||||||
|
|
||||||
|
ShowNotification --> UserReturn{User Returns?}
|
||||||
|
|
||||||
|
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
|
||||||
|
UserReturn -->|Later| KeepPaused[Video Remains Paused]
|
||||||
|
|
||||||
|
ResumeVideo --> AskResume[Resume from Saved Position]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Error States & Edge Cases
|
||||||
|
|
||||||
|
### 10.1 Network Loss During Streaming
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
|
||||||
|
|
||||||
|
LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
|
||||||
|
|
||||||
|
CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
|
||||||
|
CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
|
||||||
|
|
||||||
|
ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
|
||||||
|
|
||||||
|
WaitReconnect --> Reconnect{Reconnected?}
|
||||||
|
|
||||||
|
Reconnect -->|Yes| Resume[Resume Streaming]
|
||||||
|
Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
|
||||||
|
|
||||||
|
ShowError --> OfferRetry[Offer Retry Button]
|
||||||
|
ShowError --> OfferDownload[Offer "Download for Offline"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Server Unreachable
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Action[User Action Requires Server] --> TryConnect[Attempt Connection]
|
||||||
|
|
||||||
|
TryConnect --> Timeout{Connection<br/>Timeout?}
|
||||||
|
|
||||||
|
Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
|
||||||
|
Timeout -->|No| Success[Action Succeeds]
|
||||||
|
|
||||||
|
ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Download Failed
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
Downloading[Download in Progress] --> Failure{Failure Type?}
|
||||||
|
|
||||||
|
Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
|
||||||
|
Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
|
||||||
|
Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
|
||||||
|
|
||||||
|
Retry --> RetryCount{Retry Count<br/>< 3?}
|
||||||
|
RetryCount -->|Yes| Downloading
|
||||||
|
RetryCount -->|No| Failed[Mark as Failed]
|
||||||
|
|
||||||
|
ShowDiskError --> Failed
|
||||||
|
ShowServerError --> Failed
|
||||||
|
|
||||||
|
Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Platform-Specific UX Patterns
|
||||||
|
|
||||||
|
### 11.1 Android-Specific
|
||||||
|
|
||||||
|
**Hardware Back Button:**
|
||||||
|
- **In Full Player:** Return to previous screen, show MiniPlayer
|
||||||
|
- **In Video Player:** Stop playback, exit fullscreen
|
||||||
|
- **In Album Detail:** Return to library grid
|
||||||
|
- **At Library Home:** Exit app (show confirmation)
|
||||||
|
|
||||||
|
**System Volume Buttons:**
|
||||||
|
- **While playing audio:** Adjust playback volume
|
||||||
|
- **While controlling remote session:** Adjust remote session volume (shows session name in volume panel)
|
||||||
|
- **In menus:** Adjust system volume (default behavior)
|
||||||
|
|
||||||
|
**Share Integration:**
|
||||||
|
- Long-press album/song → Share menu
|
||||||
|
- Options: Share with other apps, Copy link
|
||||||
|
|
||||||
|
### 11.2 Linux Desktop-Specific
|
||||||
|
|
||||||
|
**Keyboard Shortcuts:**
|
||||||
|
- `Space`: Play/Pause
|
||||||
|
- `→`: Next track
|
||||||
|
- `←`: Previous track
|
||||||
|
- `/`: Focus search
|
||||||
|
- `Ctrl+Q`: Quit
|
||||||
|
|
||||||
|
**Window Behavior:**
|
||||||
|
- Minimize to tray (playback continues)
|
||||||
|
- Close window (show confirmation if playing)
|
||||||
|
- MPRIS integration for desktop media controls
|
||||||
|
|
||||||
|
**Mouse Interactions:**
|
||||||
|
- Hover over MiniPlayer: Show additional controls (volume, queue peek)
|
||||||
|
- Right-click: Context menu (Add to playlist, Go to artist, Download)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. UX Principles Summary
|
||||||
|
|
||||||
|
### 12.1 Core Principles
|
||||||
|
|
||||||
|
1. **Playback Persistence:**
|
||||||
|
- Audio playback never stops unless user explicitly stops it
|
||||||
|
- MiniPlayer visible on all screens (except video/login)
|
||||||
|
- Queue and position preserved across navigation
|
||||||
|
|
||||||
|
2. **Non-Blocking UI:**
|
||||||
|
- Downloads happen in background
|
||||||
|
- Sync operations never block user interaction
|
||||||
|
- Optimistic updates (favorite, progress) with background sync
|
||||||
|
|
||||||
|
3. **Offline-First:**
|
||||||
|
- Downloaded content works offline
|
||||||
|
- Seamless switch between online/offline
|
||||||
|
- Progress and preferences saved locally
|
||||||
|
|
||||||
|
4. **Progressive Disclosure:**
|
||||||
|
- Simple defaults, advanced options hidden
|
||||||
|
- Context menus for secondary actions
|
||||||
|
- Settings organized by category
|
||||||
|
|
||||||
|
5. **Responsive Design:**
|
||||||
|
- Mobile-first UI
|
||||||
|
- Desktop enhancements (hover states, keyboard shortcuts)
|
||||||
|
- Tablet: Grid layouts with more columns
|
||||||
|
|
||||||
|
### 12.2 Animation & Transitions
|
||||||
|
|
||||||
|
| Transition | Duration | Easing |
|
||||||
|
|------------|----------|--------|
|
||||||
|
| MiniPlayer slide up/down | 300ms | ease-out |
|
||||||
|
| Screen navigation | 200ms | ease-in-out |
|
||||||
|
| Video controls fade | 500ms | ease-out |
|
||||||
|
| Download button state change | 150ms | ease-in-out |
|
||||||
|
| Modal appear | 200ms | ease-out |
|
||||||
|
| Toast notification | 250ms | ease-in-out |
|
||||||
|
|
||||||
|
### 12.3 Touch Targets (Mobile)
|
||||||
|
|
||||||
|
| Element | Minimum Size |
|
||||||
|
|---------|--------------|
|
||||||
|
| Bottom nav buttons | 48x48 dp |
|
||||||
|
| List item (track, album) | Full width x 56 dp |
|
||||||
|
| Player controls | 56x56 dp |
|
||||||
|
| MiniPlayer | Full width x 64 dp |
|
||||||
|
| Download button | 40x40 dp |
|
||||||
|
| Favorite button | 40x40 dp |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Future UX Enhancements
|
||||||
|
|
||||||
|
### 13.1 Planned Features
|
||||||
|
|
||||||
|
1. **Gesture Navigation:**
|
||||||
|
- Swipe up on MiniPlayer → Full player
|
||||||
|
- Swipe down on full player → Back to previous screen
|
||||||
|
- Swipe between tracks in full player
|
||||||
|
|
||||||
|
2. **Queue Management UI (DR-020):**
|
||||||
|
- Drag to reorder
|
||||||
|
- Swipe to remove
|
||||||
|
- Add to queue vs. Play next
|
||||||
|
|
||||||
|
3. **Sleep Timer (UR-026):**
|
||||||
|
- Accessible from full player menu
|
||||||
|
- Presets: 15min, 30min, 1hr, End of track, End of album
|
||||||
|
- Countdown visible in MiniPlayer
|
||||||
|
|
||||||
|
4. **Home Screen (UR-034):**
|
||||||
|
- Hero banner carousel
|
||||||
|
- Continue watching/listening
|
||||||
|
- Recently added
|
||||||
|
- Personalized recommendations
|
||||||
|
|
||||||
|
5. **Cast/Remote Control Enhancements:**
|
||||||
|
- Picture-in-picture for remote sessions
|
||||||
|
- Multi-room audio (play on multiple devices)
|
||||||
|
- Handoff (transfer playback to phone from TV)
|
||||||
|
|
||||||
|
### 13.2 Accessibility Enhancements
|
||||||
|
|
||||||
|
- Screen reader optimization
|
||||||
|
- High contrast mode
|
||||||
|
- Larger text option
|
||||||
|
- Voice control integration
|
||||||
|
- Haptic feedback for controls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# E2E Test Configuration
|
||||||
|
# Copy this file to .env and fill in your test credentials
|
||||||
|
|
||||||
|
# Jellyfin Server Configuration
|
||||||
|
TEST_SERVER_URL=https://demo.jellyfin.org/stable
|
||||||
|
TEST_SERVER_NAME=Demo Server
|
||||||
|
|
||||||
|
# Test User Credentials
|
||||||
|
TEST_USERNAME=demo
|
||||||
|
TEST_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: Specific test data IDs (for testing playback, etc.)
|
||||||
|
# You can find these IDs in your Jellyfin server
|
||||||
|
TEST_MUSIC_LIBRARY_ID=
|
||||||
|
TEST_MOVIE_LIBRARY_ID=
|
||||||
|
TEST_ARTIST_ID=
|
||||||
|
TEST_ALBUM_ID=
|
||||||
|
TEST_TRACK_ID=
|
||||||
|
TEST_MOVIE_ID=
|
||||||
|
TEST_EPISODE_ID=
|
||||||
|
|
||||||
|
# Test Timeouts (milliseconds)
|
||||||
|
TEST_TIMEOUT=60000
|
||||||
|
TEST_WAIT_TIMEOUT=15000
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
# E2E Testing with WebdriverIO
|
||||||
|
|
||||||
|
End-to-end tests for JellyTau using WebdriverIO and tauri-driver. These tests run against a real Tauri app instance with an **isolated test database**.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configure test credentials (first time only)
|
||||||
|
cp e2e/.env.example e2e/.env
|
||||||
|
# Edit e2e/.env with your Jellyfin server details
|
||||||
|
|
||||||
|
# 2. Build the frontend
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# 3. Run E2E tests
|
||||||
|
bun run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Test Credentials
|
||||||
|
|
||||||
|
E2E tests use credentials from `e2e/.env` (gitignored). Copy the example file to get started:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp e2e/.env.example e2e/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**e2e/.env** (your private file):
|
||||||
|
```bash
|
||||||
|
# Your Jellyfin test server
|
||||||
|
TEST_SERVER_URL=https://your-jellyfin.example.com
|
||||||
|
TEST_SERVER_NAME=My Test Server
|
||||||
|
|
||||||
|
# Test user credentials
|
||||||
|
TEST_USERNAME=testuser
|
||||||
|
TEST_PASSWORD=yourpassword
|
||||||
|
|
||||||
|
# Optional: Specific test data IDs
|
||||||
|
TEST_MUSIC_LIBRARY_ID=abc123
|
||||||
|
TEST_ALBUM_ID=xyz789
|
||||||
|
# ... etc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- ✅ `.env` is gitignored - your credentials stay private
|
||||||
|
- ✅ Tests fall back to Jellyfin demo server if `.env` doesn't exist
|
||||||
|
- ✅ Share `.env.example` with your team so they can set up their own
|
||||||
|
|
||||||
|
### Isolated Test Database
|
||||||
|
|
||||||
|
**Your production data is safe!** E2E tests use a completely separate database:
|
||||||
|
|
||||||
|
- **Production:** `~/.local/share/com.dtourolle.jellytau/` - Your real data ✅
|
||||||
|
- **E2E Tests:** `/tmp/jellytau-test-data/` - Isolated test data ✅
|
||||||
|
|
||||||
|
This is configured via the `JELLYTAU_DATA_DIR` environment variable in `wdio.conf.ts`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── .env.example # Template for test credentials
|
||||||
|
├── .env # Your credentials (gitignored)
|
||||||
|
├── specs/ # Test specifications
|
||||||
|
│ ├── app-launch.e2e.ts # App initialization tests
|
||||||
|
│ ├── auth.e2e.ts # Authentication flow
|
||||||
|
│ └── navigation.e2e.ts # Navigation and routing
|
||||||
|
├── pageobjects/ # Page Object Model (POM)
|
||||||
|
│ ├── BasePage.ts # Base class with common methods
|
||||||
|
│ ├── LoginPage.ts # Login page interactions
|
||||||
|
│ └── HomePage.ts # Home page interactions
|
||||||
|
└── helpers/ # Test utilities
|
||||||
|
├── testConfig.ts # Load .env configuration
|
||||||
|
└── testSetup.ts # Setup helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Object Model
|
||||||
|
|
||||||
|
Tests use the Page Object Model pattern for maintainability:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: Using page objects
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Bad: Direct selectors in tests
|
||||||
|
await $("#server-url").setValue("https://...");
|
||||||
|
await $("button").click();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Using Test Configuration
|
||||||
|
|
||||||
|
Always use `testConfig` for credentials and server details:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("My Feature", () => {
|
||||||
|
it("should test something", async () => {
|
||||||
|
// Use testConfig instead of hardcoded values
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Access optional test data
|
||||||
|
if (testConfig.albumId) {
|
||||||
|
// Test with specific album
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Data IDs
|
||||||
|
|
||||||
|
For tests that need specific content (albums, tracks, etc.):
|
||||||
|
|
||||||
|
1. Find the ID in your Jellyfin server (check the URL when viewing an item)
|
||||||
|
2. Add it to your `e2e/.env`:
|
||||||
|
```bash
|
||||||
|
TEST_ALBUM_ID=abc123def456
|
||||||
|
```
|
||||||
|
3. Use it in tests:
|
||||||
|
```typescript
|
||||||
|
if (testConfig.albumId) {
|
||||||
|
await browser.url(`/album/${testConfig.albumId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Album Playback", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Login before each test
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
await LoginPage.fullLoginFlow(
|
||||||
|
testConfig.serverUrl,
|
||||||
|
testConfig.username,
|
||||||
|
testConfig.password
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should play an album", async () => {
|
||||||
|
// Skip if no test album configured
|
||||||
|
if (!testConfig.albumId) {
|
||||||
|
console.log("Skipping - no TEST_ALBUM_ID configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to album
|
||||||
|
await browser.url(`/album/${testConfig.albumId}`);
|
||||||
|
|
||||||
|
// Click play
|
||||||
|
const playButton = await $('[aria-label="Play"]');
|
||||||
|
await playButton.click();
|
||||||
|
|
||||||
|
// Verify playback started
|
||||||
|
const miniPlayer = await $(".mini-player");
|
||||||
|
expect(await miniPlayer.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
bun run test:e2e
|
||||||
|
|
||||||
|
# Run in watch mode (development)
|
||||||
|
bun run test:e2e:dev
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bun run test:e2e -- e2e/specs/auth.e2e.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Before Running
|
||||||
|
|
||||||
|
**Always build the frontend first:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
cd src-tauri && cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug binary expects built frontend files in the `build/` directory.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### app-launch.e2e.ts
|
||||||
|
Basic app initialization tests:
|
||||||
|
- App launches successfully
|
||||||
|
- UI renders correctly
|
||||||
|
- Unauthenticated users redirect to login
|
||||||
|
|
||||||
|
**Status:** ✅ Working (no credentials needed)
|
||||||
|
|
||||||
|
### auth.e2e.ts
|
||||||
|
Full authentication flow:
|
||||||
|
- Server connection (2-step process)
|
||||||
|
- Login form validation
|
||||||
|
- Error handling
|
||||||
|
- Complete auth flow
|
||||||
|
|
||||||
|
**Status:** ✅ Working with any Jellyfin server
|
||||||
|
|
||||||
|
### navigation.e2e.ts
|
||||||
|
Routing and navigation:
|
||||||
|
- Protected routes
|
||||||
|
- Redirects
|
||||||
|
- Navigation after login
|
||||||
|
|
||||||
|
**Status:** ⚠️ Needs valid credentials (configure `.env`)
|
||||||
|
|
||||||
|
## Configuration Reference
|
||||||
|
|
||||||
|
### wdio.conf.ts
|
||||||
|
|
||||||
|
Main WebdriverIO configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
port: 4444, // tauri-driver port
|
||||||
|
maxInstances: 1, // Run tests sequentially
|
||||||
|
logLevel: "warn", // Reduce noise
|
||||||
|
framework: "mocha",
|
||||||
|
timeout: 60000, // 60s test timeout
|
||||||
|
|
||||||
|
capabilities: [{
|
||||||
|
"tauri:options": {
|
||||||
|
application: "path/to/app",
|
||||||
|
env: {
|
||||||
|
JELLYTAU_DATA_DIR: "/tmp/jellytau-test-data" // Isolated DB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `TEST_SERVER_URL` | Jellyfin server URL | `https://demo.jellyfin.org/stable` |
|
||||||
|
| `TEST_SERVER_NAME` | Server display name | `Demo Server` |
|
||||||
|
| `TEST_USERNAME` | Test user username | `demo` |
|
||||||
|
| `TEST_PASSWORD` | Test user password | `` (empty) |
|
||||||
|
| `TEST_MUSIC_LIBRARY_ID` | Music library ID | undefined |
|
||||||
|
| `TEST_ALBUM_ID` | Album ID for playback tests | undefined |
|
||||||
|
| `TEST_TRACK_ID` | Track ID for tests | undefined |
|
||||||
|
| `TEST_TIMEOUT` | Mocha test timeout (ms) | `60000` |
|
||||||
|
| `TEST_WAIT_TIMEOUT` | Element wait timeout (ms) | `15000` |
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View Application During Tests
|
||||||
|
|
||||||
|
Tests run with a visible window. To pause and inspect:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
it("debug test", async () => {
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
// Pause for 10 seconds to inspect
|
||||||
|
await browser.pause(10000);
|
||||||
|
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Logs
|
||||||
|
|
||||||
|
- **WebdriverIO logs:** Console output (set `logLevel: "info"` in config)
|
||||||
|
- **tauri-driver logs:** Stdout/stderr from driver process
|
||||||
|
- **App logs:** Check app console (if running with dev tools)
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**"Connection refused" in browser body**
|
||||||
|
- Frontend not built: Run `bun run build`
|
||||||
|
- Solution: Always build before testing
|
||||||
|
|
||||||
|
**"Element not found" errors**
|
||||||
|
- Selector might be wrong
|
||||||
|
- Element not loaded yet - add wait: `await element.waitForDisplayed()`
|
||||||
|
|
||||||
|
**"Invalid session id"**
|
||||||
|
- Normal when app closes between tests
|
||||||
|
- Each test file gets a fresh app instance
|
||||||
|
|
||||||
|
**Tests fail with "no .env file"**
|
||||||
|
- Copy `e2e/.env.example` to `e2e/.env`
|
||||||
|
- Configure your Jellyfin server details
|
||||||
|
|
||||||
|
**Database still using production data**
|
||||||
|
- Check `wdio.conf.ts` has `JELLYTAU_DATA_DIR` env var
|
||||||
|
- Rebuild app: `cd src-tauri && cargo build`
|
||||||
|
|
||||||
|
## Platform Support
|
||||||
|
|
||||||
|
### Supported
|
||||||
|
|
||||||
|
- ✅ **Linux** - Primary development platform
|
||||||
|
- ✅ **Windows** - Supported (paths auto-detected)
|
||||||
|
- ✅ **macOS** - Supported (paths auto-detected)
|
||||||
|
|
||||||
|
### Not Supported
|
||||||
|
|
||||||
|
- ❌ **Android** - E2E testing requires Appium + emulators (out of scope)
|
||||||
|
- Desktop tests cover 90% of app logic anyway
|
||||||
|
|
||||||
|
## Team Collaboration
|
||||||
|
|
||||||
|
### Sharing Test Configuration
|
||||||
|
|
||||||
|
**DO:**
|
||||||
|
- ✅ Commit `e2e/.env.example` with template values
|
||||||
|
- ✅ Update README when adding new test data requirements
|
||||||
|
- ✅ Use descriptive variable names in `.env.example`
|
||||||
|
|
||||||
|
**DON'T:**
|
||||||
|
- ❌ Commit `e2e/.env` with real credentials
|
||||||
|
- ❌ Hardcode server URLs in test files
|
||||||
|
- ❌ Skip authentication in tests (always test full flows)
|
||||||
|
|
||||||
|
### Setting Up for a New Team Member
|
||||||
|
|
||||||
|
1. **Clone repo**
|
||||||
|
2. **Copy env template:** `cp e2e/.env.example e2e/.env`
|
||||||
|
3. **Configure credentials:** Edit `e2e/.env` with your Jellyfin server
|
||||||
|
4. **Build frontend:** `bun run build`
|
||||||
|
5. **Run tests:** `bun run test:e2e`
|
||||||
|
|
||||||
|
That's it! No shared credentials needed.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use testConfig:** Never hardcode credentials
|
||||||
|
2. **Use Page Objects:** Keep selectors out of test specs
|
||||||
|
3. **Wait for Elements:** Always use `.waitForDisplayed()`
|
||||||
|
4. **Independent Tests:** Each test should work standalone
|
||||||
|
5. **Skip Gracefully:** Check for optional test data before using
|
||||||
|
6. **Build First:** Always `bun run build` before running tests
|
||||||
|
7. **Clear Names:** Use descriptive `describe` and `it` blocks
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add more page objects (Player, Library, Queue, Settings)
|
||||||
|
- [ ] Create test data fixtures
|
||||||
|
- [ ] Add visual regression testing
|
||||||
|
- [ ] Mock Jellyfin API for faster, more reliable tests
|
||||||
|
- [ ] CI/CD integration (GitHub Actions)
|
||||||
|
- [ ] Test report generation
|
||||||
|
- [ ] Screenshot capture on failure
|
||||||
|
- [ ] Video recording of test runs
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [WebdriverIO Documentation](https://webdriver.io/)
|
||||||
|
- [Tauri Testing Guide](https://v2.tauri.app/develop/tests/webdriver/)
|
||||||
|
- [tauri-driver GitHub](https://github.com/tauri-apps/tauri/tree/dev/tooling/webdriver)
|
||||||
|
- [Mocha Documentation](https://mochajs.org/)
|
||||||
|
- [Page Object Model Pattern](https://webdriver.io/docs/pageobjects/)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test configuration loaded from .env file
|
||||||
|
*/
|
||||||
|
export interface TestConfig {
|
||||||
|
serverUrl: string;
|
||||||
|
serverName: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
musicLibraryId?: string;
|
||||||
|
movieLibraryId?: string;
|
||||||
|
artistId?: string;
|
||||||
|
albumId?: string;
|
||||||
|
trackId?: string;
|
||||||
|
movieId?: string;
|
||||||
|
episodeId?: string;
|
||||||
|
timeout: number;
|
||||||
|
waitTimeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load test configuration from .env file
|
||||||
|
* Falls back to demo server if .env doesn't exist
|
||||||
|
*/
|
||||||
|
export function loadTestConfig(): TestConfig {
|
||||||
|
const envPath = path.join(__dirname, "..", ".env");
|
||||||
|
const config: TestConfig = {
|
||||||
|
serverUrl: "https://demo.jellyfin.org/stable",
|
||||||
|
serverName: "Demo Server",
|
||||||
|
username: "demo",
|
||||||
|
password: "",
|
||||||
|
timeout: 60000,
|
||||||
|
waitTimeout: 15000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load .env file
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||||
|
const lines = envContent.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (line.trim().startsWith("#") || !line.trim()) continue;
|
||||||
|
|
||||||
|
const [key, ...valueParts] = line.split("=");
|
||||||
|
const value = valueParts.join("=").trim();
|
||||||
|
|
||||||
|
switch (key.trim()) {
|
||||||
|
case "TEST_SERVER_URL":
|
||||||
|
if (value) config.serverUrl = value;
|
||||||
|
break;
|
||||||
|
case "TEST_SERVER_NAME":
|
||||||
|
if (value) config.serverName = value;
|
||||||
|
break;
|
||||||
|
case "TEST_USERNAME":
|
||||||
|
if (value) config.username = value;
|
||||||
|
break;
|
||||||
|
case "TEST_PASSWORD":
|
||||||
|
config.password = value; // Can be empty
|
||||||
|
break;
|
||||||
|
case "TEST_MUSIC_LIBRARY_ID":
|
||||||
|
if (value) config.musicLibraryId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_MOVIE_LIBRARY_ID":
|
||||||
|
if (value) config.movieLibraryId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_ARTIST_ID":
|
||||||
|
if (value) config.artistId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_ALBUM_ID":
|
||||||
|
if (value) config.albumId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_TRACK_ID":
|
||||||
|
if (value) config.trackId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_MOVIE_ID":
|
||||||
|
if (value) config.movieId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_EPISODE_ID":
|
||||||
|
if (value) config.episodeId = value;
|
||||||
|
break;
|
||||||
|
case "TEST_TIMEOUT":
|
||||||
|
if (value) config.timeout = parseInt(value, 10);
|
||||||
|
break;
|
||||||
|
case "TEST_WAIT_TIMEOUT":
|
||||||
|
if (value) config.waitTimeout = parseInt(value, 10);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ No e2e/.env file found. Using demo server credentials."
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
" Copy e2e/.env.example to e2e/.env and configure your test server."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a singleton instance
|
||||||
|
export const testConfig = loadTestConfig();
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the JellyTau database and cache before tests
|
||||||
|
* This ensures each test run starts with a fresh state
|
||||||
|
*/
|
||||||
|
export function clearAppData() {
|
||||||
|
const appDataDir = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".local/share/com.dtourolle.jellytau"
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(appDataDir)) {
|
||||||
|
// Remove database file
|
||||||
|
const dbPath = path.join(appDataDir, "jellytau.db");
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
console.log("Cleared test database");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any cache files if needed
|
||||||
|
// Add more cleanup as needed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to clear app data:", error);
|
||||||
|
// Don't fail tests if cleanup fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for element with retries
|
||||||
|
* Useful for elements that might take time to appear
|
||||||
|
*/
|
||||||
|
export async function waitForElement(
|
||||||
|
selector: string,
|
||||||
|
timeout: number = 15000,
|
||||||
|
retries: number = 3
|
||||||
|
): Promise<WebdriverIO.Element> {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const element = await $(selector);
|
||||||
|
await element.waitForDisplayed({ timeout });
|
||||||
|
return element;
|
||||||
|
} catch (error) {
|
||||||
|
if (i === retries - 1) throw error;
|
||||||
|
await browser.pause(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Element ${selector} not found after ${retries} retries`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export default class BasePage {
|
||||||
|
async waitForElement(selector: string, timeout: number = 10000) {
|
||||||
|
const element = await $(selector);
|
||||||
|
await element.waitForDisplayed({ timeout });
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickElement(selector: string) {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
await element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterText(selector: string, text: string) {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
await element.setValue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getText(selector: string): Promise<string> {
|
||||||
|
const element = await this.waitForElement(selector);
|
||||||
|
return await element.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async isElementDisplayed(selector: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const element = await $(selector);
|
||||||
|
return await element.isDisplayed();
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import BasePage from "./BasePage";
|
||||||
|
|
||||||
|
class HomePage extends BasePage {
|
||||||
|
// Selectors
|
||||||
|
get loadingSpinner() {
|
||||||
|
return $(".animate-spin");
|
||||||
|
}
|
||||||
|
|
||||||
|
get browseLibrariesButton() {
|
||||||
|
return $("button*=Browse all libraries");
|
||||||
|
}
|
||||||
|
|
||||||
|
get offlineBanner() {
|
||||||
|
return $(".bg-amber-600\\/90");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carousel sections
|
||||||
|
get heroSection() {
|
||||||
|
return $("div"); // Hero banner would need specific selector
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async waitForHomePageLoad(timeout: number = 15000) {
|
||||||
|
// Wait for loading spinner to disappear
|
||||||
|
try {
|
||||||
|
await this.loadingSpinner.waitForDisplayed({ timeout: 5000 });
|
||||||
|
await this.loadingSpinner.waitForDisplayed({ timeout, reverse: true });
|
||||||
|
} catch {
|
||||||
|
// Spinner might not appear if page loads quickly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOffline(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.offlineBanner.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickBrowseLibraries() {
|
||||||
|
await this.browseLibrariesButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasContent(): Promise<boolean> {
|
||||||
|
// Check if browse button exists (indicates loaded state)
|
||||||
|
try {
|
||||||
|
return await this.browseLibrariesButton.isExisting();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new HomePage();
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import BasePage from "./BasePage";
|
||||||
|
|
||||||
|
class LoginPage extends BasePage {
|
||||||
|
// Selectors
|
||||||
|
get pageTitle() {
|
||||||
|
return $("h1");
|
||||||
|
}
|
||||||
|
|
||||||
|
get serverUrlInput() {
|
||||||
|
return $("#server-url");
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectButton() {
|
||||||
|
return $('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
get usernameInput() {
|
||||||
|
return $("#username");
|
||||||
|
}
|
||||||
|
|
||||||
|
get passwordInput() {
|
||||||
|
return $("#password");
|
||||||
|
}
|
||||||
|
|
||||||
|
get signInButton() {
|
||||||
|
return $('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage() {
|
||||||
|
return $(".bg-red-900\\/50");
|
||||||
|
}
|
||||||
|
|
||||||
|
get backButton() {
|
||||||
|
return $("button*=Back");
|
||||||
|
}
|
||||||
|
|
||||||
|
get serverNameDisplay() {
|
||||||
|
return $('p.text-\\[var\\(--color-jellyfin\\)\\]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async waitForLoginPage(timeout: number = 10000) {
|
||||||
|
await this.serverUrlInput.waitForDisplayed({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterServerUrl(url: string) {
|
||||||
|
await this.serverUrlInput.setValue(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickConnect() {
|
||||||
|
await this.connectButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectToServer(url: string) {
|
||||||
|
await this.enterServerUrl(url);
|
||||||
|
await this.clickConnect();
|
||||||
|
|
||||||
|
// Wait for transition to login form
|
||||||
|
await this.usernameInput.waitForDisplayed({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterUsername(username: string) {
|
||||||
|
await this.usernameInput.setValue(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enterPassword(password: string) {
|
||||||
|
await this.passwordInput.setValue(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickSignIn() {
|
||||||
|
await this.signInButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
await this.enterUsername(username);
|
||||||
|
await this.enterPassword(password);
|
||||||
|
await this.clickSignIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fullLoginFlow(serverUrl: string, username: string, password: string) {
|
||||||
|
await this.waitForLoginPage();
|
||||||
|
await this.connectToServer(serverUrl);
|
||||||
|
await this.login(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnServerStep(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.serverUrlInput.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isOnLoginStep(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.usernameInput.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErrorMessage(): Promise<string> {
|
||||||
|
await this.errorMessage.waitForDisplayed({ timeout: 5000 });
|
||||||
|
return await this.errorMessage.getText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasError(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await this.errorMessage.isDisplayed();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new LoginPage();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
|
||||||
|
describe("Application Launch", () => {
|
||||||
|
it("should launch the application", async () => {
|
||||||
|
// Wait for body element to appear
|
||||||
|
const body = await $("body");
|
||||||
|
await body.waitForDisplayed({ timeout: 15000 });
|
||||||
|
|
||||||
|
// Verify app launched successfully
|
||||||
|
expect(await body.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the main app container", async () => {
|
||||||
|
// The app has a root div with specific classes
|
||||||
|
const appContainer = await $("div.h-screen.bg-\\[var\\(--color-background\\)\\]");
|
||||||
|
|
||||||
|
// Verify the main container exists
|
||||||
|
expect(await appContainer.isExisting()).toBe(true);
|
||||||
|
expect(await appContainer.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show JellyTau branding", async () => {
|
||||||
|
// The app should show JellyTau title on login page (default state)
|
||||||
|
const title = await $("h1");
|
||||||
|
await title.waitForDisplayed({ timeout: 10000 });
|
||||||
|
|
||||||
|
const titleText = await title.getText();
|
||||||
|
expect(titleText).toContain("JellyTau");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redirect unauthenticated users to login", async () => {
|
||||||
|
// Wait for login page elements to appear
|
||||||
|
const serverUrlInput = await $("#server-url");
|
||||||
|
await serverUrlInput.waitForDisplayed({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify we're on the login page
|
||||||
|
expect(await serverUrlInput.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Authentication Flow", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts fresh - app should redirect to login
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Server Connection", () => {
|
||||||
|
it("should display the server connection form", async () => {
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
expect(await LoginPage.pageTitle.getText()).toContain("JellyTau");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show server URL input field", async () => {
|
||||||
|
const serverInput = await LoginPage.serverUrlInput;
|
||||||
|
|
||||||
|
expect(await serverInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await serverInput.getAttribute("placeholder")).toContain("jellyfin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have a disabled connect button when URL is empty", async () => {
|
||||||
|
const connectButton = await LoginPage.connectButton;
|
||||||
|
|
||||||
|
// Button should be disabled when input is empty
|
||||||
|
expect(await connectButton.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable connect button when URL is entered", async () => {
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
|
||||||
|
const connectButton = await LoginPage.connectButton;
|
||||||
|
expect(await connectButton.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error for invalid server URL", async () => {
|
||||||
|
await LoginPage.enterServerUrl("not-a-valid-url");
|
||||||
|
await LoginPage.clickConnect();
|
||||||
|
|
||||||
|
// Wait for error to appear
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
expect(await LoginPage.hasError()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should transition to login form on successful connection", async () => {
|
||||||
|
// Using configured test server
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
|
||||||
|
// Should now be on login step
|
||||||
|
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("User Login", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Connect to configured test server before each login test
|
||||||
|
await LoginPage.connectToServer(testConfig.serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display login form after server connection", async () => {
|
||||||
|
expect(await LoginPage.usernameInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await LoginPage.passwordInput.isDisplayed()).toBe(true);
|
||||||
|
expect(await LoginPage.signInButton.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show server information", async () => {
|
||||||
|
// Server name and URL should be displayed
|
||||||
|
const serverName = await LoginPage.serverNameDisplay;
|
||||||
|
expect(await serverName.isDisplayed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have back button to return to server selection", async () => {
|
||||||
|
expect(await LoginPage.backButton.isDisplayed()).toBe(true);
|
||||||
|
|
||||||
|
await LoginPage.backButton.click();
|
||||||
|
await browser.pause(500);
|
||||||
|
|
||||||
|
// Should be back on server step
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable sign in button when username is empty", async () => {
|
||||||
|
const signInButton = await LoginPage.signInButton;
|
||||||
|
expect(await signInButton.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enable sign in button when username is entered", async () => {
|
||||||
|
await LoginPage.enterUsername("demo");
|
||||||
|
|
||||||
|
const signInButton = await LoginPage.signInButton;
|
||||||
|
expect(await signInButton.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show error for invalid credentials", async () => {
|
||||||
|
await LoginPage.login("invalid-user", "wrong-password");
|
||||||
|
|
||||||
|
// Wait for error
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
expect(await LoginPage.hasError()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable this test by configuring e2e/.env with valid credentials
|
||||||
|
it.skip("should successfully login with valid credentials", async () => {
|
||||||
|
await LoginPage.login(testConfig.username, testConfig.password);
|
||||||
|
|
||||||
|
// Wait for redirect to home page
|
||||||
|
await browser.pause(3000);
|
||||||
|
|
||||||
|
// Should redirect away from login page
|
||||||
|
const currentUrl = await browser.getUrl();
|
||||||
|
expect(currentUrl).not.toContain("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Full Authentication Flow", () => {
|
||||||
|
it("should complete full auth flow with test server", async () => {
|
||||||
|
// Test the complete flow
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
// Step 1: Enter server URL
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
await LoginPage.enterServerUrl(testConfig.serverUrl);
|
||||||
|
await LoginPage.clickConnect();
|
||||||
|
|
||||||
|
// Wait for transition
|
||||||
|
await browser.pause(2000);
|
||||||
|
|
||||||
|
// Step 2: Should be on login form
|
||||||
|
expect(await LoginPage.isOnLoginStep()).toBe(true);
|
||||||
|
|
||||||
|
// Step 3: Enter credentials
|
||||||
|
await LoginPage.enterUsername(testConfig.username);
|
||||||
|
await LoginPage.enterPassword(testConfig.password);
|
||||||
|
|
||||||
|
// Verify form is filled
|
||||||
|
const username = await LoginPage.usernameInput.getValue();
|
||||||
|
expect(username).toBe(testConfig.username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect } from "@wdio/globals";
|
||||||
|
import LoginPage from "../pageobjects/LoginPage";
|
||||||
|
import HomePage from "../pageobjects/HomePage";
|
||||||
|
import { testConfig } from "../helpers/testConfig";
|
||||||
|
|
||||||
|
describe("Navigation", () => {
|
||||||
|
it("should redirect unauthenticated users to login", async () => {
|
||||||
|
// App should automatically redirect to login when not authenticated
|
||||||
|
await LoginPage.waitForLoginPage();
|
||||||
|
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent direct access to protected routes", async () => {
|
||||||
|
// Try to navigate to a protected route
|
||||||
|
await browser.url("http://localhost:4444/session/fake-session-id/url");
|
||||||
|
await browser.pause(1000);
|
||||||
|
|
||||||
|
// Should redirect back to login
|
||||||
|
await LoginPage.waitForLoginPage(5000);
|
||||||
|
expect(await LoginPage.isOnServerStep()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// This test requires valid authentication - configure e2e/.env to enable
|
||||||
|
it.skip("should allow navigation after login", async () => {
|
||||||
|
// Login first
|
||||||
|
await LoginPage.fullLoginFlow(
|
||||||
|
testConfig.serverUrl,
|
||||||
|
testConfig.username,
|
||||||
|
testConfig.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for home page
|
||||||
|
await HomePage.waitForHomePageLoad();
|
||||||
|
|
||||||
|
// Should be able to navigate
|
||||||
|
expect(await HomePage.hasContent()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.11.2",
|
|
||||||
"notes": "\n### 🐛 Fixes\n\n- **Subtitles appear on screen on Android.** Turning one on did nothing, even\n once they were loading again: the player hands finished subtitles to a view\n that draws them, and on the native Android path there was no such view — so\n every cue was decoded, delivered and dropped. There is one now, sitting over\n the picture and under the controls, following the video's shape when the\n screen turns. This was hidden behind the loading failure fixed in v0.11.1;\n with nothing to select, there had never been a cue to lose. (UR-020, UR-003 →\n DR-260)",
|
|
||||||
"pub_date": "2026-08-23T21:48:32Z",
|
|
||||||
"platforms": {
|
|
||||||
"linux-x86_64": {
|
|
||||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaXNHUmIxbjlnTzB3dXAzbmU5OG4wUXBRVmZ0Y1lPN3F5OU5DV3o1N1R2eVdFWG1BNDBaR2ZwSXptOHJtQytiTVpVTTRkbkxKR1h1QXQrQk9kdG5LS1FnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3NTE4ODAwCWZpbGU6SmVsbHlUYXVfMC4xMS4yX2FtZDY0LkFwcEltYWdlCmk0UVdnbU5RZzFNdkZua1dwdFIyMUo1RTZVR200ak1RUXFZaVlrTmVlaFlTYWJvTFRMK1hHRStmSkJobHVKK0NVTW5sOGVrSWp5NVh5TjVrM096cURBPT0K",
|
|
||||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.11.2/JellyTau_0.11.2_amd64.AppImage"
|
|
||||||
},
|
|
||||||
"windows-x86_64": {
|
|
||||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaXZibUxGcDlxNlVscTBRNVdBMlhzRDBWalI0TGhLRDJqdWhTOE5iNW5wb1dQUjdiblZVRUd4MzZJeHRVK1gwa2s0WDhwL2FYem9OY3cyZkJ6SUZVbGdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3NTE5NzkzCWZpbGU6SmVsbHlUYXVfMC4xMS4yX3g2NC1zZXR1cC5leGUKbUdQajVUeVlmNDRtM3lTUFVMYVJ5YTFBUkdXUkxNcUJhaTRBRkdSVk1NR2tCd20rYUt1OW9hcGZDbkE2R1BvRHg1SjdyRGtKZzhwK1M3ZTFaRnY0QVE9PQo=",
|
|
||||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.11.2/JellyTau_0.11.2_x64-setup.exe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "jellytau",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "bun@1.3.5",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:e2e": "wdio run ./wdio.conf.ts",
|
||||||
|
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||||
|
"test:all": "./scripts/test-all.sh",
|
||||||
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
|
"android:build": "./scripts/build-android.sh",
|
||||||
|
"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:dev": "./scripts/build-and-deploy.sh",
|
||||||
|
"android:check": "./scripts/check-android.sh",
|
||||||
|
"android:logs": "./scripts/logcat.sh",
|
||||||
|
"clean": "./scripts/clean.sh",
|
||||||
|
"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",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
|
"svelte-dnd-action": "^0.9.69"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.9.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.16",
|
||||||
|
"@wdio/cli": "^9.5.0",
|
||||||
|
"@wdio/local-runner": "^9.5.0",
|
||||||
|
"@wdio/mocha-framework": "^9.5.0",
|
||||||
|
"@wdio/spec-reporter": "^9.5.0",
|
||||||
|
"happy-dom": "^20.0.11",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"svelte": "^5.47.1",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vitest": ">=1.0.0 <5.0.0",
|
||||||
|
"webdriverio": "^9.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Development Scripts
|
||||||
|
|
||||||
|
Collection of utility scripts for building, testing, and deploying JellyTau.
|
||||||
|
|
||||||
|
## Testing Scripts
|
||||||
|
|
||||||
|
### `test-all.sh`
|
||||||
|
Run all tests (frontend + Rust backend).
|
||||||
|
```bash
|
||||||
|
./scripts/test-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test-frontend.sh`
|
||||||
|
Run frontend tests only.
|
||||||
|
```bash
|
||||||
|
./scripts/test-frontend.sh # Run all tests
|
||||||
|
./scripts/test-frontend.sh --watch # Watch mode
|
||||||
|
./scripts/test-frontend.sh --ui # Open UI
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test-rust.sh`
|
||||||
|
Run Rust tests only.
|
||||||
|
```bash
|
||||||
|
./scripts/test-rust.sh # Run all tests
|
||||||
|
./scripts/test-rust.sh -- --nocapture # Show println! output
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android Scripts
|
||||||
|
|
||||||
|
### `build-android.sh`
|
||||||
|
Build the Android APK.
|
||||||
|
```bash
|
||||||
|
./scripts/build-android.sh # Debug build
|
||||||
|
./scripts/build-android.sh release # Release build
|
||||||
|
```
|
||||||
|
|
||||||
|
### `deploy-android.sh`
|
||||||
|
Install APK on connected Android device.
|
||||||
|
```bash
|
||||||
|
./scripts/deploy-android.sh # Deploy debug APK
|
||||||
|
./scripts/deploy-android.sh release # Deploy release APK
|
||||||
|
```
|
||||||
|
|
||||||
|
### `build-and-deploy.sh`
|
||||||
|
Build and deploy in one command.
|
||||||
|
```bash
|
||||||
|
./scripts/build-and-deploy.sh # Build + deploy debug
|
||||||
|
./scripts/build-and-deploy.sh release # Build + deploy release
|
||||||
|
```
|
||||||
|
|
||||||
|
### `check-android.sh`
|
||||||
|
Check Android development environment setup.
|
||||||
|
```bash
|
||||||
|
./scripts/check-android.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### `logcat.sh`
|
||||||
|
View Android logcat filtered for the app.
|
||||||
|
```bash
|
||||||
|
./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
|
||||||
|
|
||||||
|
### `clean.sh`
|
||||||
|
Clean all build artifacts.
|
||||||
|
```bash
|
||||||
|
./scripts/clean.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## NPM Script Aliases
|
||||||
|
|
||||||
|
You can also run these via npm/bun:
|
||||||
|
```bash
|
||||||
|
bun run test:all # All tests
|
||||||
|
bun run test:rust # Rust tests
|
||||||
|
bun run android:build # Build Android APK
|
||||||
|
bun run android:deploy # Deploy to device
|
||||||
|
bun run android:dev # Build + deploy debug
|
||||||
|
bun run android:check # Check environment
|
||||||
|
bun run clean # Clean artifacts
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 JellyTau Android Development Helper"
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
# Setup environment
|
||||||
|
echo "Setting up environment..."
|
||||||
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" || true
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
|
export NDK_HOME="$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk 2>/dev/null | head -1)"
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
echo -e "\n✓ Checking prerequisites..."
|
||||||
|
|
||||||
|
if ! command -v rustc &> /dev/null; then
|
||||||
|
echo "❌ Rust not found. Please install from https://rustup.rs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v adb &> /dev/null; then
|
||||||
|
echo "❌ ADB not found. Please install Android SDK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$ANDROID_HOME" ]; then
|
||||||
|
echo "⚠️ ANDROID_HOME not found at $ANDROID_HOME"
|
||||||
|
echo " Please install Android SDK or update the path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for connected devices
|
||||||
|
echo -e "\n📱 Connected devices:"
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Menu
|
||||||
|
echo -e "\n📋 What would you like to do?"
|
||||||
|
echo "1) Run in development mode (hot reload)"
|
||||||
|
echo "2) Build debug APK"
|
||||||
|
echo "3) Build release APK"
|
||||||
|
echo "4) Install debug APK to device"
|
||||||
|
echo "5) Check environment"
|
||||||
|
read -p "Select option (1-5): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo -e "\n🔨 Starting development mode..."
|
||||||
|
bun run tauri android dev
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo -e "\n🔨 Building debug APK..."
|
||||||
|
bun run tauri android build --debug
|
||||||
|
echo -e "\n✅ Debug APK built at:"
|
||||||
|
echo " src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo -e "\n🔨 Building release APK..."
|
||||||
|
bun run tauri android build
|
||||||
|
echo -e "\n✅ Release APK built at:"
|
||||||
|
echo " src-tauri/gen/android/app/build/outputs/apk/release/"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
APK="src-tauri/gen/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
if [ -f "$APK" ]; then
|
||||||
|
echo -e "\n📲 Installing to device..."
|
||||||
|
adb install -r "$APK"
|
||||||
|
echo "✅ Installed!"
|
||||||
|
else
|
||||||
|
echo "❌ APK not found. Build it first (option 2)"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo -e "\n🔍 Environment Check:"
|
||||||
|
echo " Rust: $(rustc --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " Cargo: $(cargo --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " Bun: $(bun --version 2>/dev/null || echo 'Not found')"
|
||||||
|
echo " ADB: $(adb --version 2>/dev/null | head -1 || echo 'Not found')"
|
||||||
|
echo " ANDROID_HOME: $ANDROID_HOME"
|
||||||
|
echo " NDK_HOME: $NDK_HOME"
|
||||||
|
echo ""
|
||||||
|
echo " Rust Android targets:"
|
||||||
|
rustup target list 2>/dev/null | grep android | grep installed || echo " None installed"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Invalid option"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and deploy Android APK in one command
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
echo "🚀 Build and Deploy Android APK"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build APK
|
||||||
|
./scripts/build-android.sh "$BUILD_TYPE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Deploy APK
|
||||||
|
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build Android APK
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Source Rust environment
|
||||||
|
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Set Android environment variables
|
||||||
|
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
|
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
|
||||||
|
|
||||||
|
echo "🤖 Building Android APK..."
|
||||||
|
echo "Android SDK: $ANDROID_HOME"
|
||||||
|
echo "NDK: $NDK_HOME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build type: debug or release (default: 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
|
||||||
|
echo "🔄 Syncing Android sources..."
|
||||||
|
./scripts/sync-android-sources.sh
|
||||||
|
|
||||||
|
# Step 2: Build the frontend first to avoid dev server issues
|
||||||
|
echo "🎨 Building frontend..."
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Step 2: Build Android APK
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
echo "📦 Building release APK..."
|
||||||
|
bun run tauri android build --apk true
|
||||||
|
else
|
||||||
|
echo "📦 Building debug APK..."
|
||||||
|
bun run tauri android build --apk true --debug
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ APK build complete!"
|
||||||
|
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
|
||||||
@@ -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,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Check Android development environment
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 Checking Android development environment..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check ADB
|
||||||
|
if command -v adb &> /dev/null; then
|
||||||
|
echo "✅ ADB installed: $(adb version | head -1)"
|
||||||
|
else
|
||||||
|
echo "❌ ADB not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Android SDK
|
||||||
|
if [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
echo "✅ Android SDK found at: $HOME/Android/Sdk"
|
||||||
|
else
|
||||||
|
echo "❌ Android SDK not found at: $HOME/Android/Sdk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check NDK
|
||||||
|
if [ -d "$HOME/Android/Sdk/ndk" ]; then
|
||||||
|
NDK_VERSION=$(ls "$HOME/Android/Sdk/ndk" | head -1)
|
||||||
|
echo "✅ NDK found: $NDK_VERSION"
|
||||||
|
else
|
||||||
|
echo "❌ NDK not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Rust
|
||||||
|
if command -v rustc &> /dev/null; then
|
||||||
|
echo "✅ Rust installed: $(rustc --version)"
|
||||||
|
else
|
||||||
|
echo "❌ Rust not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Cargo
|
||||||
|
if command -v cargo &> /dev/null; then
|
||||||
|
echo "✅ Cargo installed: $(cargo --version)"
|
||||||
|
else
|
||||||
|
echo "❌ Cargo not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for connected devices
|
||||||
|
echo ""
|
||||||
|
echo "📱 Connected Android devices:"
|
||||||
|
if adb devices | grep -q "device$"; then
|
||||||
|
adb devices | grep "device$"
|
||||||
|
else
|
||||||
|
echo "⚠️ No devices connected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Environment check complete!"
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Requirements Coverage Checker
|
||||||
|
# Extracts @req tags from codebase and compares with README.md
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REQUIREMENTS_FILE="README.md"
|
||||||
|
SOURCE_DIRS="src-tauri/ src/"
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " Requirements Coverage Report"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
|
||||||
|
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
|
||||||
|
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
|
||||||
|
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
|
||||||
|
sort -u)
|
||||||
|
|
||||||
|
total_reqs=$(echo "$requirements" | wc -l)
|
||||||
|
implemented=0
|
||||||
|
partial=0
|
||||||
|
planned=0
|
||||||
|
missing=0
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Category Breakdown:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
for category in UR IR DR JA; do
|
||||||
|
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
|
||||||
|
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Implementation Status:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
for req in $requirements; do
|
||||||
|
# Count full implementations
|
||||||
|
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
|
||||||
|
|
||||||
|
# Count partial implementations
|
||||||
|
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
# Count planned
|
||||||
|
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ "$full_count" -gt 0 ]; then
|
||||||
|
echo "✅ $req: $full_count implementation(s)"
|
||||||
|
((implemented++))
|
||||||
|
elif [ "$partial_count" -gt 0 ]; then
|
||||||
|
echo "🔶 $req: $partial_count partial implementation(s)"
|
||||||
|
((partial++))
|
||||||
|
elif [ "$planned_count" -gt 0 ]; then
|
||||||
|
echo "📋 $req: Planned (not yet implemented)"
|
||||||
|
((planned++))
|
||||||
|
else
|
||||||
|
echo "❌ $req: No implementation found"
|
||||||
|
((missing++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
printf "Total Requirements: %3d\n" "$total_reqs"
|
||||||
|
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
|
||||||
|
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
|
||||||
|
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
|
||||||
|
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Exit code based on missing critical requirements
|
||||||
|
if [ "$missing" -gt 0 ]; then
|
||||||
|
echo "⚠️ Warning: $missing requirements have no implementation"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✨ All requirements have implementations!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Test Coverage Report
|
||||||
|
# Links test requirements to implementations
|
||||||
|
#
|
||||||
|
|
||||||
|
echo "Test Coverage Report"
|
||||||
|
echo "===================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
|
||||||
|
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
|
||||||
|
sort -u)
|
||||||
|
|
||||||
|
total_tests=0
|
||||||
|
covered=0
|
||||||
|
uncovered=0
|
||||||
|
|
||||||
|
for req in $test_reqs; do
|
||||||
|
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
|
||||||
|
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
((total_tests++))
|
||||||
|
|
||||||
|
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
|
||||||
|
echo "✅ $req: $test_count test(s), $impl_count implementation(s)"
|
||||||
|
((covered++))
|
||||||
|
elif [ "$impl_count" -eq 0 ]; then
|
||||||
|
echo "⚠️ $req: $test_count test(s) but no implementation"
|
||||||
|
((uncovered++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
printf "Total Test Requirements: %3d\n" "$total_tests"
|
||||||
|
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
|
||||||
|
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Clean build artifacts
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧹 Cleaning build artifacts..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clean frontend
|
||||||
|
if [ -d "node_modules/.cache" ]; then
|
||||||
|
echo "Cleaning Vite cache..."
|
||||||
|
rm -rf node_modules/.cache
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d ".svelte-kit" ]; then
|
||||||
|
echo "Cleaning SvelteKit build..."
|
||||||
|
rm -rf .svelte-kit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "build" ]; then
|
||||||
|
echo "Cleaning build directory..."
|
||||||
|
rm -rf build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean Rust
|
||||||
|
echo "Cleaning Rust target..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo clean
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Clean Android
|
||||||
|
if [ -d "src-tauri/gen/android" ]; then
|
||||||
|
echo "Cleaning Android build..."
|
||||||
|
cd src-tauri/gen/android
|
||||||
|
./gradlew clean 2>/dev/null || true
|
||||||
|
cd ../../..
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Clean complete!"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy APK to connected Android device
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "📱 Deploying to Android device..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if device is connected
|
||||||
|
if ! adb devices | grep -q "device$"; then
|
||||||
|
echo "❌ No Android device connected!"
|
||||||
|
echo "Please connect a device or start an emulator."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build type: debug or release (default: debug)
|
||||||
|
BUILD_TYPE="${1:-debug}"
|
||||||
|
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||||
|
else
|
||||||
|
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if APK exists
|
||||||
|
if [ ! -f "$APK_PATH" ]; then
|
||||||
|
echo "❌ APK not found at: $APK_PATH"
|
||||||
|
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Installing APK: $APK_PATH"
|
||||||
|
adb install -r "$APK_PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo "🚀 Launch the app on your device"
|
||||||
@@ -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`
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Find all files implementing a specific requirement
|
||||||
|
#
|
||||||
|
# Usage: ./find-req-implementations.sh UR-004
|
||||||
|
#
|
||||||
|
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
echo "Usage: $0 <REQUIREMENT_ID>"
|
||||||
|
echo "Example: $0 UR-004"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQ_ID=$1
|
||||||
|
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " Implementations of $REQ_ID"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Full implementations
|
||||||
|
echo "Full Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
grep -v "@req-partial" | \
|
||||||
|
grep -v "@req-planned" | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Partial implementations
|
||||||
|
echo "Partial Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Planned
|
||||||
|
echo "Planned Implementations:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' | \
|
||||||
|
sed 's/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
echo "Test Cases:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
|
||||||
|
sed 's/src-tauri\/src\///' || echo " (none)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Generate traceability matrix in Markdown format
|
||||||
|
#
|
||||||
|
|
||||||
|
echo "# Requirements Traceability Matrix"
|
||||||
|
echo ""
|
||||||
|
echo "**Generated**: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo ""
|
||||||
|
echo "| Requirement | Files Implementing | Status | Notes |"
|
||||||
|
echo "|-------------|--------------------|--------|-------|"
|
||||||
|
|
||||||
|
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" README.md | sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | sort -u)
|
||||||
|
|
||||||
|
for req in $requirements; do
|
||||||
|
files=$(grep -rl "@req: $req" src-tauri/ src/ 2>/dev/null | \
|
||||||
|
sed 's|src-tauri/src/||; s|src/||' | \
|
||||||
|
paste -sd, -)
|
||||||
|
|
||||||
|
partial_files=$(grep -rl "@req-partial: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
planned=$(grep -rl "@req-planned: $req" src-tauri/ src/ 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ -n "$files" ]; then
|
||||||
|
status="✅ Done"
|
||||||
|
notes=""
|
||||||
|
elif [ "$partial_files" -gt 0 ]; then
|
||||||
|
status="🔶 Partial"
|
||||||
|
notes="Platform-specific"
|
||||||
|
elif [ "$planned" -gt 0 ]; then
|
||||||
|
status="📋 Planned"
|
||||||
|
notes="Not implemented"
|
||||||
|
else
|
||||||
|
status="❌ Missing"
|
||||||
|
notes="No implementation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "| $req | ${files:-N/A} | $status | $notes |"
|
||||||
|
done
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# View Android logcat output filtered for the app
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_PACKAGE="com.jellytau.app"
|
||||||
|
|
||||||
|
echo "📱 Showing logcat for $APP_PACKAGE"
|
||||||
|
echo "Press Ctrl+C to stop"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Filter logcat for the app's package name
|
||||||
|
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sync Android source files from src-tauri/android to src-tauri/gen/android
|
||||||
|
# This ensures the generated build directory has the latest source files
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/main/java/com/dtourolle/jellytau"
|
||||||
|
TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/java/com/dtourolle/jellytau"
|
||||||
|
|
||||||
|
echo "Syncing Android sources..."
|
||||||
|
echo " From: $SOURCE_DIR"
|
||||||
|
echo " To: $TARGET_DIR"
|
||||||
|
|
||||||
|
# Create target directory if it doesn't exist
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
|
||||||
|
# Remove old copies of player and security directories
|
||||||
|
rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
|
||||||
|
|
||||||
|
# Copy the directories
|
||||||
|
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
|
||||||
|
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
|
||||||
|
|
||||||
|
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||||
|
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||||
|
if [ -f "$kt_file" ]; then
|
||||||
|
cp "$kt_file" "$TARGET_DIR/"
|
||||||
|
echo " Copied: $(basename "$kt_file")"
|
||||||
|
fi
|
||||||
|
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"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run all tests (frontend and backend)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧪 Running all tests..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "📦 Running frontend tests..."
|
||||||
|
bun run test
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🦀 Running Rust tests..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo test
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ All tests passed!"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run frontend tests only
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "📦 Running frontend tests..."
|
||||||
|
bun run test "$@"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run Rust tests only
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🦀 Running Rust tests..."
|
||||||
|
cd src-tauri
|
||||||
|
cargo test "$@"
|
||||||
|
cd ..
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# Includes Android projects, schemas, and all other generated files
|
||||||
|
/gen/
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
|
||||||
|
# Android/Gradle (if not using gen/)
|
||||||
|
.gradle
|
||||||
|
local.properties
|
||||||
|
**/android/**/build/
|
||||||
|
**/android/.gradle/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
[package]
|
||||||
|
name = "jellytau"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "jellytau_lib"
|
||||||
|
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]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
rand = "0.8"
|
||||||
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
urlencoding = "2"
|
||||||
|
futures-util = "0.3"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# SQLite for offline storage
|
||||||
|
tokio-rusqlite = "0.6"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
directories = "5"
|
||||||
|
|
||||||
|
# Secure credential storage (system keyring with encrypted file fallback)
|
||||||
|
keyring = "3"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
sha2 = "0.10"
|
||||||
|
getrandom = "0.2"
|
||||||
|
log = "0.4"
|
||||||
|
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
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
hostname = "0.4"
|
||||||
|
libc = "0.2"
|
||||||
|
# Use latest git version for better MPV version compatibility
|
||||||
|
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
|
||||||
|
|
||||||
|
# JNI for Android ExoPlayer integration
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "0.21"
|
||||||
|
ndk-context = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.24.0"
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ⚠️ IMPORTANT: Android Build File Locations
|
||||||
|
|
||||||
|
## Critical Information for Future Development
|
||||||
|
|
||||||
|
**DO NOT EDIT FILES IN `src-tauri/gen/android/` DIRECTLY!**
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
This project has **TWO** sets of Android source files:
|
||||||
|
|
||||||
|
1. **`src-tauri/android/`** - **SOURCE FILES** (edit these!)
|
||||||
|
- This is the template directory
|
||||||
|
- Changes here need to be copied to the generated directory
|
||||||
|
|
||||||
|
2. **`src-tauri/gen/android/`** - **GENERATED BUILD DIRECTORY** (do not edit directly!)
|
||||||
|
- This is where Gradle actually builds the APK
|
||||||
|
- Files here may be overwritten during builds
|
||||||
|
|
||||||
|
### How to Make Changes to Android Code
|
||||||
|
|
||||||
|
When you need to modify Android/Kotlin files:
|
||||||
|
|
||||||
|
1. **Edit the files in `src-tauri/android/src/main/java/`**
|
||||||
|
2. **Build using the provided script (which auto-syncs files)**
|
||||||
|
```bash
|
||||||
|
./scripts/build-android.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The build script automatically runs `./scripts/sync-android-sources.sh` which copies:
|
||||||
|
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/player/` → generated directory
|
||||||
|
- `src-tauri/android/src/main/java/com/dtourolle/jellytau/security/` → generated directory
|
||||||
|
|
||||||
|
3. **Manual sync (if needed)**
|
||||||
|
```bash
|
||||||
|
./scripts/sync-android-sources.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Matters
|
||||||
|
|
||||||
|
- If you only edit `src-tauri/gen/android/`, your changes will be lost
|
||||||
|
- If you only edit `src-tauri/android/`, your changes won't be in the build
|
||||||
|
- **You must edit both** (or edit source and copy to generated)
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
|
||||||
|
Player-related Kotlin files:
|
||||||
|
- `player/JellyTauPlayer.kt` - Main player implementation
|
||||||
|
- `player/JellyTauPlaybackService.kt` - MediaSession service for lockscreen controls
|
||||||
|
- `security/SecureStorage.kt` - Android Keystore integration for secure credential storage
|
||||||
|
|
||||||
|
Always check BOTH locations exist and match after making changes!
|
||||||
@@ -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,285 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JellyTau media player wrapper using ExoPlayer (Media3).
|
||||||
|
*
|
||||||
|
* This class is designed to be called from Rust via JNI.
|
||||||
|
* All player operations are marshalled to the main thread.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class JellyTauPlayer(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Position update interval in milliseconds */
|
||||||
|
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
||||||
|
|
||||||
|
/** Singleton instance for JNI access */
|
||||||
|
@Volatile
|
||||||
|
private var instance: JellyTauPlayer? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Load the native library for JNI callbacks
|
||||||
|
System.loadLibrary("jellytau_lib")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the player singleton.
|
||||||
|
* Called from Rust via JNI during Android startup.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = JellyTauPlayer(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance.
|
||||||
|
* @throws IllegalStateException if not initialized
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): JellyTauPlayer {
|
||||||
|
return instance ?: throw IllegalStateException("JellyTauPlayer not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val exoPlayer: ExoPlayer
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
private var positionUpdateJob: Job? = null
|
||||||
|
|
||||||
|
/** Current media ID being played */
|
||||||
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Create ExoPlayer on main thread
|
||||||
|
exoPlayer = ExoPlayer.Builder(context).build()
|
||||||
|
|
||||||
|
// Set up player listener
|
||||||
|
exoPlayer.addListener(object : Player.Listener {
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
// Media loaded and ready
|
||||||
|
val duration = exoPlayer.duration / 1000.0
|
||||||
|
nativeOnMediaLoaded(duration)
|
||||||
|
|
||||||
|
val state = if (exoPlayer.isPlaying) "playing" else "paused"
|
||||||
|
nativeOnStateChanged(state, currentMediaId)
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
// Playback completed
|
||||||
|
stopPositionUpdates()
|
||||||
|
nativeOnPlaybackEnded()
|
||||||
|
}
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
nativeOnBuffering(0)
|
||||||
|
}
|
||||||
|
Player.STATE_IDLE -> {
|
||||||
|
// Player is idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
val state = if (isPlaying) "playing" else "paused"
|
||||||
|
nativeOnStateChanged(state, currentMediaId)
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
startPositionUpdates()
|
||||||
|
} else {
|
||||||
|
stopPositionUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
val message = error.message ?: "Unknown playback error"
|
||||||
|
val recoverable = error.errorCode != PlaybackException.ERROR_CODE_UNSPECIFIED
|
||||||
|
nativeOnError(message, recoverable)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load media from a URL.
|
||||||
|
* @param url The media URL to load
|
||||||
|
* @param mediaId The unique ID for this media item
|
||||||
|
*/
|
||||||
|
fun load(url: String, mediaId: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
currentMediaId = mediaId
|
||||||
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
|
exoPlayer.prepare()
|
||||||
|
exoPlayer.playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start or resume playback.
|
||||||
|
*/
|
||||||
|
fun play() {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause playback.
|
||||||
|
*/
|
||||||
|
fun pause() {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop playback and release media.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
mainHandler.post {
|
||||||
|
stopPositionUpdates()
|
||||||
|
exoPlayer.stop()
|
||||||
|
exoPlayer.clearMediaItems()
|
||||||
|
currentMediaId = null
|
||||||
|
nativeOnStateChanged("idle", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a position.
|
||||||
|
* @param positionSeconds Position in seconds
|
||||||
|
*/
|
||||||
|
fun seek(positionSeconds: Double) {
|
||||||
|
mainHandler.post {
|
||||||
|
val positionMs = (positionSeconds * 1000).toLong()
|
||||||
|
exoPlayer.seekTo(positionMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the volume.
|
||||||
|
* @param volume Volume level from 0.0 to 1.0
|
||||||
|
*/
|
||||||
|
fun setVolume(volume: Float) {
|
||||||
|
mainHandler.post {
|
||||||
|
exoPlayer.volume = volume.coerceIn(0f, 1f)
|
||||||
|
nativeOnVolumeChanged(exoPlayer.volume, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current playback position in seconds.
|
||||||
|
*/
|
||||||
|
fun getPosition(): Double {
|
||||||
|
return exoPlayer.currentPosition / 1000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total duration in seconds.
|
||||||
|
*/
|
||||||
|
fun getDuration(): Double {
|
||||||
|
val duration = exoPlayer.duration
|
||||||
|
return if (duration > 0) duration / 1000.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current volume.
|
||||||
|
*/
|
||||||
|
fun getVolume(): Float {
|
||||||
|
return exoPlayer.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if media is currently loaded.
|
||||||
|
*/
|
||||||
|
fun isLoaded(): Boolean {
|
||||||
|
return exoPlayer.playbackState == Player.STATE_READY ||
|
||||||
|
exoPlayer.playbackState == Player.STATE_BUFFERING
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release player resources.
|
||||||
|
* Call when the app is closing.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
mainHandler.post {
|
||||||
|
stopPositionUpdates()
|
||||||
|
coroutineScope.cancel()
|
||||||
|
exoPlayer.release()
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPositionUpdates() {
|
||||||
|
positionUpdateJob?.cancel()
|
||||||
|
positionUpdateJob = coroutineScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
val position = exoPlayer.currentPosition / 1000.0
|
||||||
|
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||||
|
nativeOnPositionUpdate(position, duration)
|
||||||
|
}
|
||||||
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopPositionUpdates() {
|
||||||
|
positionUpdateJob?.cancel()
|
||||||
|
positionUpdateJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native methods to call back to Rust via JNI
|
||||||
|
// These will be implemented in the Rust android module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when position updates during playback.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnPositionUpdate(position: Double, duration: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when player state changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnStateChanged(state: String, mediaId: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when media has finished loading.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnMediaLoaded(duration: Double)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when playback reaches the end.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnPlaybackEnded()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when buffering state changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnBuffering(percent: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a playback error occurs.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnError(message: String, recoverable: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when volume changes.
|
||||||
|
*/
|
||||||
|
private external fun nativeOnVolumeChanged(volume: Float, muted: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.dtourolle.jellytau.player"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 24
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-common:1.5.1")
|
||||||
|
implementation("androidx.media3:media3-session:1.5.1")
|
||||||
|
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Enable hardware acceleration for video playback performance -->
|
||||||
|
<application android:hardwareAccelerated="true" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var configAttempts = 0
|
||||||
|
private val maxConfigAttempts = 10
|
||||||
|
private var audioFocusRequest: AudioFocusRequest? = null
|
||||||
|
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Configure WebView for media playback after Tauri initialization
|
||||||
|
handler.postDelayed({
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureWebViewForMedia() {
|
||||||
|
try {
|
||||||
|
val webView = findWebView(window.decorView)
|
||||||
|
|
||||||
|
if (webView == null) {
|
||||||
|
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
|
||||||
|
if (configAttempts < maxConfigAttempts) {
|
||||||
|
configAttempts++
|
||||||
|
handler.postDelayed({
|
||||||
|
configureWebViewForMedia()
|
||||||
|
}, 200)
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to find WebView after $maxConfigAttempts attempts")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
|
||||||
|
|
||||||
|
// Add JavaScript interface for audio focus control
|
||||||
|
webView.addJavascriptInterface(object : Any() {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun requestAudioFocus() {
|
||||||
|
handler.post { this@MainActivity.requestAudioFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun abandonAudioFocus() {
|
||||||
|
handler.post { this@MainActivity.abandonAudioFocus() }
|
||||||
|
}
|
||||||
|
}, "AndroidAudioFocus")
|
||||||
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
|
||||||
|
|
||||||
|
// Set WebChromeClient to handle video playback and audio focus
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||||
|
super.onShowCustomView(view, callback)
|
||||||
|
android.util.Log.d("MainActivity", "Video entered fullscreen")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHideCustomView() {
|
||||||
|
super.onHideCustomView()
|
||||||
|
android.util.Log.d("MainActivity", "Video exited fullscreen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.d("MainActivity", "WebChromeClient configured")
|
||||||
|
|
||||||
|
webView.settings.apply {
|
||||||
|
// CRITICAL: Enable media playback without user gesture requirement
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
android.util.Log.d("MainActivity", "Set mediaPlaybackRequiresUserGesture = false")
|
||||||
|
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
allowFileAccess = true
|
||||||
|
allowContentAccess = true
|
||||||
|
setRenderPriority(WebSettings.RenderPriority.HIGH)
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute JavaScript to ensure any video elements are unmuted and request audio focus
|
||||||
|
webView.post {
|
||||||
|
webView.evaluateJavascript("""
|
||||||
|
(function() {
|
||||||
|
console.log('[Android] Ensuring video elements are unmuted');
|
||||||
|
const videos = document.getElementsByTagName('video');
|
||||||
|
for (let video of videos) {
|
||||||
|
video.muted = false;
|
||||||
|
video.volume = 1.0;
|
||||||
|
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
|
||||||
|
|
||||||
|
// Add event listeners to manage audio focus
|
||||||
|
video.addEventListener('play', function() {
|
||||||
|
console.log('[Android] Video play event - requesting audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.requestAudioFocus();
|
||||||
|
}
|
||||||
|
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('pause', function() {
|
||||||
|
console.log('[Android] Video pause event - abandoning audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.abandonAudioFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('ended', function() {
|
||||||
|
console.log('[Android] Video ended event - abandoning audio focus');
|
||||||
|
if (typeof AndroidAudioFocus !== 'undefined') {
|
||||||
|
AndroidAudioFocus.abandonAudioFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('volumechange', function() {
|
||||||
|
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor for new video elements
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const videos = document.getElementsByTagName('video');
|
||||||
|
for (let video of videos) {
|
||||||
|
if (video.muted) {
|
||||||
|
video.muted = false;
|
||||||
|
video.volume = 1.0;
|
||||||
|
console.log('[Android] New video found and unmuted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
console.log('[Android] Video unmute observer installed');
|
||||||
|
})();
|
||||||
|
""".trimIndent(), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWebView(view: android.view.View): WebView? {
|
||||||
|
if (view is WebView) {
|
||||||
|
android.util.Log.d("MainActivity", "Found WebView!")
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view is android.view.ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
val child = view.getChildAt(i)
|
||||||
|
val webView = findWebView(child)
|
||||||
|
if (webView != null) {
|
||||||
|
return webView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val audioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||||
|
.setAudioAttributes(audioAttributes)
|
||||||
|
.setAcceptsDelayedFocusGain(true)
|
||||||
|
.setOnAudioFocusChangeListener { focusChange ->
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus request result: $result")
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val result = audioManager.requestAudioFocus(
|
||||||
|
{ focusChange ->
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
|
||||||
|
},
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN
|
||||||
|
)
|
||||||
|
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonAudioFocus() {
|
||||||
|
android.util.Log.d("MainActivity", "Abandoning audio focus")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
audioFocusRequest?.let {
|
||||||
|
audioManager.abandonAudioFocusRequest(it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.abandonAudioFocus { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* This class handles attaching and detaching the native ExoPlayer SurfaceView
|
||||||
|
* so that it renders video content behind the WebView.
|
||||||
|
*/
|
||||||
|
object VideoOverlayManager {
|
||||||
|
|
||||||
|
private var attachedSurfaceView: SurfaceView? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the video SurfaceView to the Activity's content view.
|
||||||
|
*
|
||||||
|
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
||||||
|
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to attach the surface to
|
||||||
|
*/
|
||||||
|
fun attachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
// Get the SurfaceView from JellyTauPlayer
|
||||||
|
val player = JellyTauPlayer.getInstance()
|
||||||
|
val surfaceView = player.getSurfaceView()
|
||||||
|
|
||||||
|
if (surfaceView == null) {
|
||||||
|
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the root content view
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
|
||||||
|
// Remove from parent if already attached elsewhere
|
||||||
|
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||||
|
|
||||||
|
// Configure layout params to fill the screen
|
||||||
|
val layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to content view at index 0 (behind WebView)
|
||||||
|
contentView.addView(surfaceView, 0, layoutParams)
|
||||||
|
attachedSurfaceView = surfaceView
|
||||||
|
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the video SurfaceView from the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to detach the surface from
|
||||||
|
*/
|
||||||
|
fun detachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
contentView.removeView(surfaceView)
|
||||||
|
attachedSurfaceView = null
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a video surface is currently attached.
|
||||||
|
*
|
||||||
|
* @return true if a surface is attached, false otherwise
|
||||||
|
*/
|
||||||
|
fun isVideoSurfaceAttached(): Boolean {
|
||||||
|
return attachedSurfaceView != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.LruCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memory cache for album artwork bitmaps with LRU eviction.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - LruCache for efficient memory usage (1/8 of heap, typically 12-16MB)
|
||||||
|
* - Automatic bitmap scaling to 512x512 max (lock screen optimal size)
|
||||||
|
* - Async HTTP downloads using Dispatchers.IO (non-blocking)
|
||||||
|
* - Graceful error handling for network failures and corrupted images
|
||||||
|
* - Singleton pattern for app-wide access
|
||||||
|
*
|
||||||
|
* Cache lifecycle: In-memory only, cleared on app termination.
|
||||||
|
*/
|
||||||
|
class AlbumArtCache(context: Context) {
|
||||||
|
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
||||||
|
private val cacheSize = maxMemory / 8 // Use 1/8 of available heap
|
||||||
|
|
||||||
|
private val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||||
|
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||||
|
return bitmap.byteCount / 1024 // Size in KB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get artwork bitmap for the given URL.
|
||||||
|
*
|
||||||
|
* Checks memory cache first, then downloads from network if not cached.
|
||||||
|
* Scaling and error handling are done transparently.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The bitmap, or null if download failed or URL is invalid
|
||||||
|
*/
|
||||||
|
suspend fun getArtwork(url: String): Bitmap? {
|
||||||
|
// Check memory cache first
|
||||||
|
memoryCache.get(url)?.let { return it }
|
||||||
|
|
||||||
|
// Download from network if not cached
|
||||||
|
return downloadAndCache(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download artwork from network and add to cache.
|
||||||
|
*
|
||||||
|
* Runs on IO dispatcher to avoid blocking the main thread.
|
||||||
|
* Automatically scales large images to 512x512 max.
|
||||||
|
* On failure, logs error and returns null.
|
||||||
|
*
|
||||||
|
* @param url The Jellyfin server artwork URL
|
||||||
|
* @return The cached bitmap, or null if download/decode failed
|
||||||
|
*/
|
||||||
|
private suspend fun downloadAndCache(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
|
connection.doInput = true
|
||||||
|
connection.connectTimeout = 5000
|
||||||
|
connection.readTimeout = 5000
|
||||||
|
connection.connect()
|
||||||
|
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
android.util.Log.w("AlbumArtCache", "Failed to download artwork: HTTP ${connection.responseCode}")
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
val input = connection.inputStream
|
||||||
|
val bitmap = BitmapFactory.decodeStream(input)
|
||||||
|
input.close()
|
||||||
|
connection.disconnect()
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
// Scale down if too large (lock screen doesn't need full resolution)
|
||||||
|
val scaled = scaleDownIfNeeded(it, MAX_ARTWORK_SIZE)
|
||||||
|
memoryCache.put(url, scaled)
|
||||||
|
android.util.Log.d("AlbumArtCache", "Cached artwork: ${scaled.width}x${scaled.height}")
|
||||||
|
scaled
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AlbumArtCache", "Failed to download artwork: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale down bitmap if it exceeds max size while maintaining aspect ratio.
|
||||||
|
*
|
||||||
|
* @param bitmap The original bitmap
|
||||||
|
* @param maxSize Maximum width or height (e.g., 512)
|
||||||
|
* @return Original bitmap if smaller than maxSize, else scaled version
|
||||||
|
*/
|
||||||
|
private fun scaleDownIfNeeded(bitmap: Bitmap, maxSize: Int): Bitmap {
|
||||||
|
if (bitmap.width <= maxSize && bitmap.height <= maxSize) return bitmap
|
||||||
|
|
||||||
|
val ratio = minOf(
|
||||||
|
maxSize.toFloat() / bitmap.width,
|
||||||
|
maxSize.toFloat() / bitmap.height
|
||||||
|
)
|
||||||
|
|
||||||
|
val newWidth = (bitmap.width * ratio).toInt()
|
||||||
|
val newHeight = (bitmap.height * ratio).toInt()
|
||||||
|
|
||||||
|
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached bitmaps from memory.
|
||||||
|
*
|
||||||
|
* Useful for memory-constrained situations or settings reset.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
memoryCache.evictAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ARTWORK_SIZE = 512 // 512x512 max for lock screen
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: AlbumArtCache? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create singleton instance.
|
||||||
|
*
|
||||||
|
* Thread-safe with double-checked locking pattern.
|
||||||
|
*
|
||||||
|
* @param context Android context for initialization
|
||||||
|
* @return The singleton AlbumArtCache instance
|
||||||
|
*/
|
||||||
|
fun getInstance(context: Context): AlbumArtCache {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: AlbumArtCache(context).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.media.MediaCodecList
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects hardware codec capabilities using MediaCodecList.
|
||||||
|
*
|
||||||
|
* This class queries the device's media codec capabilities and reports
|
||||||
|
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
||||||
|
*/
|
||||||
|
object CodecDetector {
|
||||||
|
private const val TAG = "CodecDetector"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class to hold detected codec capabilities.
|
||||||
|
*/
|
||||||
|
data class CodecCapabilities(
|
||||||
|
val videoCodecs: List<String>,
|
||||||
|
val audioCodecs: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect all hardware decoders available on this device.
|
||||||
|
*
|
||||||
|
* Uses Android's MediaCodecList API to query supported MIME types
|
||||||
|
* and maps them to Jellyfin codec names.
|
||||||
|
*
|
||||||
|
* @return CodecCapabilities containing lists of supported video and audio codecs
|
||||||
|
*/
|
||||||
|
fun detectHardwareCodecs(): CodecCapabilities {
|
||||||
|
val videoCodecs = mutableSetOf<String>()
|
||||||
|
val audioCodecs = mutableSetOf<String>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all codec infos (including both hardware and software codecs)
|
||||||
|
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
|
||||||
|
|
||||||
|
for (codecInfo in codecList.codecInfos) {
|
||||||
|
// Only interested in decoders (not encoders)
|
||||||
|
if (codecInfo.isEncoder) continue
|
||||||
|
|
||||||
|
// Check if it's a hardware codec
|
||||||
|
val isHardware = !codecInfo.isSoftwareOnly
|
||||||
|
|
||||||
|
for (type in codecInfo.supportedTypes) {
|
||||||
|
when {
|
||||||
|
type.startsWith("video/") -> {
|
||||||
|
val codec = mapMimeTypeToCodecName(type, isVideo = true)
|
||||||
|
if (codec != null) {
|
||||||
|
videoCodecs.add(codec)
|
||||||
|
Log.d(TAG, "Video codec: $codec (MIME: $type, Hardware: $isHardware)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type.startsWith("audio/") -> {
|
||||||
|
val codec = mapMimeTypeToCodecName(type, isVideo = false)
|
||||||
|
if (codec != null) {
|
||||||
|
audioCodecs.add(codec)
|
||||||
|
Log.d(TAG, "Audio codec: $codec (MIME: $type, Hardware: $isHardware)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
||||||
|
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error detecting codecs", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return CodecCapabilities(
|
||||||
|
videoCodecs = videoCodecs.sorted(),
|
||||||
|
audioCodecs = audioCodecs.sorted()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Android MIME types to Jellyfin codec names.
|
||||||
|
*
|
||||||
|
* Based on Jellyfin's codec naming conventions and Android's
|
||||||
|
* supported MIME type constants.
|
||||||
|
*
|
||||||
|
* @param mimeType Android MIME type (e.g., "video/avc", "audio/mp4a-latm")
|
||||||
|
* @param isVideo Whether this is a video codec
|
||||||
|
* @return Jellyfin codec name or null if unknown
|
||||||
|
*/
|
||||||
|
private fun mapMimeTypeToCodecName(mimeType: String, isVideo: Boolean): String? {
|
||||||
|
return when (mimeType) {
|
||||||
|
// Video codecs
|
||||||
|
"video/avc" -> "h264"
|
||||||
|
"video/hevc" -> "hevc"
|
||||||
|
"video/x-vnd.on2.vp8" -> "vp8"
|
||||||
|
"video/x-vnd.on2.vp9" -> "vp9"
|
||||||
|
"video/av01" -> "av1"
|
||||||
|
"video/mp4v-es" -> "mpeg4"
|
||||||
|
"video/3gpp" -> "h263"
|
||||||
|
"video/mpeg2" -> "mpeg2video"
|
||||||
|
"video/divx" -> "divx"
|
||||||
|
"video/xvid" -> "xvid"
|
||||||
|
"video/x-ms-wmv" -> "wmv"
|
||||||
|
"video/vc1" -> "vc1"
|
||||||
|
|
||||||
|
// Audio codecs
|
||||||
|
"audio/mp4a-latm" -> "aac"
|
||||||
|
"audio/mpeg" -> "mp3"
|
||||||
|
"audio/mpeg-L1" -> "mp1"
|
||||||
|
"audio/mpeg-L2" -> "mp2"
|
||||||
|
"audio/opus" -> "opus"
|
||||||
|
"audio/vorbis" -> "vorbis"
|
||||||
|
"audio/flac" -> "flac"
|
||||||
|
"audio/alac" -> "alac"
|
||||||
|
"audio/ac3" -> "ac3"
|
||||||
|
"audio/eac3" -> "eac3"
|
||||||
|
"audio/eac3-joc" -> "eac3"
|
||||||
|
"audio/dts" -> "dts"
|
||||||
|
"audio/vnd.dts.hd" -> "dts"
|
||||||
|
"audio/x-ms-wma" -> "wma"
|
||||||
|
"audio/amr-nb" -> "amrnb"
|
||||||
|
"audio/amr-wb" -> "amrwb"
|
||||||
|
"audio/3gpp" -> "amrnb"
|
||||||
|
"audio/raw" -> "pcm"
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Unknown MIME type: $mimeType")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
package com.dtourolle.jellytau.player
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.media.VolumeProviderCompat
|
||||||
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.session.MediaSession
|
||||||
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MediaSessionService for lockscreen controls and media notifications.
|
||||||
|
*
|
||||||
|
* This service creates a MediaSession that integrates with the system's
|
||||||
|
* media controls (lockscreen, notification shade, Bluetooth devices).
|
||||||
|
*
|
||||||
|
* Media commands are routed back to Rust via JNI to ensure proper
|
||||||
|
* queue management for next/previous track operations.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class JellyTauPlaybackService : MediaSessionService() {
|
||||||
|
|
||||||
|
private var mediaSession: MediaSession? = null
|
||||||
|
private var mediaSessionCompat: MediaSessionCompat? = null
|
||||||
|
private var wrappedPlayer: androidx.media3.common.ForwardingPlayer? = null
|
||||||
|
private var volumeProvider: VolumeProviderCompat? = null
|
||||||
|
private var isRemoteVolumeEnabled = false
|
||||||
|
private var remoteVolumeLevel = 50 // 0-100
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "playback_channel"
|
||||||
|
private const val NOTIFICATION_CHANNEL_NAME = "Playback"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: JellyTauPlaybackService? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the service instance if running.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): JellyTauPlaybackService? = instance
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure native library is loaded for JNI callbacks
|
||||||
|
System.loadLibrary("jellytau_lib")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
|
||||||
|
// Create notification channel for Android O+
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
NOTIFICATION_CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Media playback controls"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if JellyTauPlayer is initialized
|
||||||
|
if (!JellyTauPlayer.isInitialized()) {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "JellyTauPlayer not initialized, initializing now")
|
||||||
|
// Initialize the player with application context
|
||||||
|
JellyTauPlayer.initialize(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the existing JellyTauPlayer instance with its ExoPlayer
|
||||||
|
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||||
|
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
||||||
|
|
||||||
|
// Wrap the ExoPlayer to intercept commands
|
||||||
|
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||||
|
override fun play() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.play()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("play")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.pause()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("pause")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToNext() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekToNext()
|
||||||
|
// Then notify Rust for queue management
|
||||||
|
nativeOnMediaCommand("next")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekToPrevious()
|
||||||
|
// Then notify Rust for queue management
|
||||||
|
nativeOnMediaCommand("previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekTo(positionMs: Long) {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.seekTo(positionMs)
|
||||||
|
// Then notify Rust of seek
|
||||||
|
val positionSeconds = positionMs / 1000.0
|
||||||
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
// Execute immediately for instant lockscreen response
|
||||||
|
super.stop()
|
||||||
|
// Then notify Rust for state management
|
||||||
|
nativeOnMediaCommand("stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create MediaSession with the wrapped player and callback for command handling
|
||||||
|
mediaSession = MediaSession.Builder(this, wrappedPlayer!!)
|
||||||
|
.setCallback(object : MediaSession.Callback {
|
||||||
|
override fun onSetMediaItems(
|
||||||
|
mediaSession: MediaSession,
|
||||||
|
controller: MediaSession.ControllerInfo,
|
||||||
|
mediaItems: MutableList<androidx.media3.common.MediaItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
startPositionMs: Long
|
||||||
|
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||||
|
return Futures.immediateFuture(
|
||||||
|
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Create MediaSessionCompat for volume control and lock screen button handling
|
||||||
|
// We need this alongside Media3's MediaSession because MediaSessionCompat provides
|
||||||
|
// VolumeProviderCompat support for remote volume control (routing hardware button presses)
|
||||||
|
mediaSessionCompat = MediaSessionCompat(this, "JellyTauMediaSession").apply {
|
||||||
|
setFlags(
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
|
||||||
|
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
|
||||||
|
)
|
||||||
|
isActive = true
|
||||||
|
|
||||||
|
// Set callback to handle lock screen button presses
|
||||||
|
setCallback(object : MediaSessionCompat.Callback() {
|
||||||
|
override fun onPlay() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||||
|
wrappedPlayer?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||||
|
wrappedPlayer?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToNext() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||||
|
wrappedPlayer?.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToPrevious() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||||
|
wrappedPlayer?.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
|
wrappedPlayer?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSeekTo(position: Long) {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
|
wrappedPlayer?.seekTo(position)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Start as foreground service immediately to avoid crash
|
||||||
|
// Media3 will replace this with its own notification
|
||||||
|
val notification = createBasicNotification()
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createBasicNotification(): Notification {
|
||||||
|
// Create a media-style notification with lockscreen controls
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle("JellyTau")
|
||||||
|
.setContentText("Playing")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setStyle(
|
||||||
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
|
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||||
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_previous,
|
||||||
|
"Previous",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_pause,
|
||||||
|
"Pause",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_next,
|
||||||
|
"Next",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the MediaSession metadata and playback state.
|
||||||
|
* This updates both the MediaSession and the notification.
|
||||||
|
*/
|
||||||
|
fun updateMediaMetadata(
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String?,
|
||||||
|
duration: Long,
|
||||||
|
position: Long,
|
||||||
|
isPlaying: Boolean
|
||||||
|
) {
|
||||||
|
val session = mediaSessionCompat ?: return
|
||||||
|
|
||||||
|
// Update MediaSession metadata
|
||||||
|
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_ARTIST, artist)
|
||||||
|
.putLong(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION, duration)
|
||||||
|
|
||||||
|
album?.let {
|
||||||
|
metadataBuilder.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
|
// Update MediaSession playback state
|
||||||
|
val stateBuilder = PlaybackStateCompat.Builder()
|
||||||
|
.setActions(
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_STOP or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
||||||
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
|
)
|
||||||
|
.setState(
|
||||||
|
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
|
position,
|
||||||
|
1.0f
|
||||||
|
)
|
||||||
|
|
||||||
|
session.setPlaybackState(stateBuilder.build())
|
||||||
|
|
||||||
|
// Update the notification
|
||||||
|
updateNotification(title, artist, isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the notification with current media metadata and playback state.
|
||||||
|
* This should be called whenever metadata or playback state changes.
|
||||||
|
*/
|
||||||
|
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
|
||||||
|
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(artist)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setStyle(
|
||||||
|
androidx.media.app.NotificationCompat.MediaStyle()
|
||||||
|
.setMediaSession(mediaSessionCompat?.sessionToken)
|
||||||
|
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_previous,
|
||||||
|
"Previous",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
|
||||||
|
if (isPlaying) "Pause" else "Play",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
if (isPlaying) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
android.R.drawable.ic_media_next,
|
||||||
|
"Next",
|
||||||
|
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
|
||||||
|
this,
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(isPlaying)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||||
|
return mediaSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable remote volume control for remote playback (e.g., casting to Jellyfin session).
|
||||||
|
* Volume button presses will be sent to Rust for forwarding to the remote session.
|
||||||
|
*
|
||||||
|
* Uses MediaSessionCompat with VolumeProviderCompat to intercept hardware volume buttons.
|
||||||
|
*
|
||||||
|
* @param initialVolume Initial volume level (0-100)
|
||||||
|
*/
|
||||||
|
fun enableRemoteVolume(initialVolume: Int) {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Enabling remote volume control (volume=$initialVolume)")
|
||||||
|
isRemoteVolumeEnabled = true
|
||||||
|
remoteVolumeLevel = initialVolume.coerceIn(0, 100)
|
||||||
|
|
||||||
|
val session = mediaSessionCompat ?: run {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a VolumeProvider for remote volume control
|
||||||
|
volumeProvider = object : VolumeProviderCompat(
|
||||||
|
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, // Control type: absolute volume
|
||||||
|
100, // Max volume (0-100)
|
||||||
|
remoteVolumeLevel // Initial volume
|
||||||
|
) {
|
||||||
|
override fun onSetVolumeTo(volume: Int) {
|
||||||
|
if (!isRemoteVolumeEnabled) return
|
||||||
|
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume set to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAdjustVolume(direction: Int) {
|
||||||
|
if (!isRemoteVolumeEnabled) return
|
||||||
|
|
||||||
|
when (direction) {
|
||||||
|
android.media.AudioManager.ADJUST_RAISE -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume up to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
|
||||||
|
// Update the current volume so slider reflects the change
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
android.media.AudioManager.ADJUST_LOWER -> {
|
||||||
|
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume down to $remoteVolumeLevel")
|
||||||
|
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
|
||||||
|
// Update the current volume so slider reflects the change
|
||||||
|
currentVolume = remoteVolumeLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the volume provider on the media session to route hardware volume buttons
|
||||||
|
session.setPlaybackToRemote(volumeProvider!!)
|
||||||
|
|
||||||
|
// Set playback state to make Android show the volume UI
|
||||||
|
// This tells Android that this session is actively controlling media playback
|
||||||
|
val playbackState = PlaybackStateCompat.Builder()
|
||||||
|
.setState(
|
||||||
|
PlaybackStateCompat.STATE_PLAYING,
|
||||||
|
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
||||||
|
1.0f
|
||||||
|
)
|
||||||
|
.setActions(
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
||||||
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
session.setPlaybackState(playbackState)
|
||||||
|
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume control enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable remote volume control and return to local volume control.
|
||||||
|
* Volume buttons will control system media volume (ExoPlayer volume).
|
||||||
|
*/
|
||||||
|
fun disableRemoteVolume() {
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Disabling remote volume control")
|
||||||
|
isRemoteVolumeEnabled = false
|
||||||
|
|
||||||
|
val session = mediaSessionCompat ?: run {
|
||||||
|
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch back to local audio stream (device volume)
|
||||||
|
session.setPlaybackToLocal(android.media.AudioManager.STREAM_MUSIC)
|
||||||
|
|
||||||
|
// Clear the playback state
|
||||||
|
val idleState = PlaybackStateCompat.Builder()
|
||||||
|
.setState(
|
||||||
|
PlaybackStateCompat.STATE_NONE,
|
||||||
|
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
|
||||||
|
0.0f
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
session.setPlaybackState(idleState)
|
||||||
|
|
||||||
|
// Clear the volume provider
|
||||||
|
volumeProvider = null
|
||||||
|
|
||||||
|
// Reset volume level to default
|
||||||
|
remoteVolumeLevel = 50
|
||||||
|
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume control disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the remote volume level.
|
||||||
|
* Call this when volume changes on the remote session to sync the local state.
|
||||||
|
*
|
||||||
|
* @param volume Volume level (0-100)
|
||||||
|
*/
|
||||||
|
fun updateRemoteVolume(volume: Int) {
|
||||||
|
remoteVolumeLevel = volume.coerceIn(0, 100)
|
||||||
|
// Update the volume provider's current volume so the UI slider reflects the change
|
||||||
|
volumeProvider?.currentVolume = remoteVolumeLevel
|
||||||
|
android.util.Log.d("JellyTauPlaybackService", "Remote volume updated to $remoteVolumeLevel")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
mediaSession?.run {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
mediaSession = null
|
||||||
|
|
||||||
|
mediaSessionCompat?.run {
|
||||||
|
isActive = false
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
mediaSessionCompat = null
|
||||||
|
volumeProvider = null
|
||||||
|
|
||||||
|
instance = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
// Stop the service when the app is swiped away, unless audio is playing
|
||||||
|
val player = mediaSession?.player
|
||||||
|
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI callback to Rust for media commands.
|
||||||
|
* Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
|
||||||
|
*/
|
||||||
|
private external fun nativeOnMediaCommand(command: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI callback to Rust for remote volume changes.
|
||||||
|
* Commands: "SetVolume", "VolumeUp", "VolumeDown"
|
||||||
|
* @param command The volume command
|
||||||
|
* @param volume The volume level (0-100)
|
||||||
|
*/
|
||||||
|
private external fun nativeOnRemoteVolumeChange(command: String, volume: Int)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.dtourolle.jellytau.security
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure storage for credentials using Android Keystore.
|
||||||
|
* Provides encrypted storage for sensitive data like API tokens.
|
||||||
|
*/
|
||||||
|
class SecureStorage private constructor(context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "jellytau_credentials_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val PREFS_NAME = "jellytau_secure_prefs"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: SecureStorage? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = SecureStorage(context.applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(): SecureStorage {
|
||||||
|
return instance ?: throw IllegalStateException("SecureStorage not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure encryption key exists
|
||||||
|
if (!keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateKey() {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_AES,
|
||||||
|
KEYSTORE_PROVIDER
|
||||||
|
)
|
||||||
|
|
||||||
|
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenParameterSpec)
|
||||||
|
keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSecretKey(): SecretKey {
|
||||||
|
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCredential(key: String, value: String) {
|
||||||
|
try {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
|
||||||
|
|
||||||
|
val iv = cipher.iv
|
||||||
|
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
// Store IV + encrypted data as base64
|
||||||
|
val combined = iv + encrypted
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
prefs.edit().putString(key, encoded).apply()
|
||||||
|
Log.d(TAG, "Saved credential: $key")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save credential: $key", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCredential(key: String): String? {
|
||||||
|
try {
|
||||||
|
val encoded = prefs.getString(key, null) ?: return null
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
|
||||||
|
// Extract IV (first 12 bytes for GCM)
|
||||||
|
val iv = combined.copyOfRange(0, 12)
|
||||||
|
val encrypted = combined.copyOfRange(12, combined.size)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(128, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
|
||||||
|
|
||||||
|
val decrypted = cipher.doFinal(encrypted)
|
||||||
|
return String(decrypted, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get credential: $key", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCredential(key: String) {
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
Log.d(TAG, "Deleted credential: $key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// JNI-compatible methods (called from Rust)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a token (JNI-compatible version).
|
||||||
|
* @return true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
@JvmOverloads
|
||||||
|
fun saveToken(key: String, value: String): Boolean {
|
||||||
|
return try {
|
||||||
|
saveCredential(key, value)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "saveToken failed for key: $key", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a token (JNI-compatible version).
|
||||||
|
* @return token string or null if not found
|
||||||
|
*/
|
||||||
|
fun getToken(key: String): String? {
|
||||||
|
return getCredential(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a token (JNI-compatible version).
|
||||||
|
* @return true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
fun deleteToken(key: String): Boolean {
|
||||||
|
return try {
|
||||||
|
deleteCredential(key)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "deleteToken failed for key: $key", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme -->
|
||||||
|
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<!-- Status bar color -->
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Make status bar icons dark or light based on background -->
|
||||||
|
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
|
||||||
|
<!-- Don't draw behind status bar -->
|
||||||
|
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||||
|
<!-- Ensure content doesn't extend into system bars -->
|
||||||
|
<item name="android:fitsSystemWindows">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"core:path:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,411 @@
|
|||||||
|
pub mod session_verifier;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::jellyfin::http_client::HttpClient;
|
||||||
|
use crate::connectivity::ConnectivityMonitor;
|
||||||
|
|
||||||
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
|
/// Server information returned from Jellyfin
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[specta(rename = "AuthServerInfo")]
|
||||||
|
pub struct ServerInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub id: String,
|
||||||
|
/// Normalized server URL with protocol and no trailing slash
|
||||||
|
pub normalized_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User information
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub primary_image_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication result
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AuthResult {
|
||||||
|
pub user: User,
|
||||||
|
pub access_token: String,
|
||||||
|
pub server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active session for restoration
|
||||||
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Session {
|
||||||
|
pub user_id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub server_id: String,
|
||||||
|
pub server_url: String,
|
||||||
|
pub server_name: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub verified: bool,
|
||||||
|
pub needs_reauth: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jellyfin API response types (PascalCase from server)
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct PublicSystemInfo {
|
||||||
|
server_name: String,
|
||||||
|
version: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct AuthenticateByNameResponse {
|
||||||
|
user: JellyfinUser,
|
||||||
|
access_token: String,
|
||||||
|
server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct JellyfinUser {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
server_id: String,
|
||||||
|
primary_image_tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication manager
|
||||||
|
pub struct AuthManager {
|
||||||
|
http_client: Arc<HttpClient>,
|
||||||
|
current_session: Arc<RwLock<Option<Session>>>,
|
||||||
|
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthManager {
|
||||||
|
/// Create a new auth manager
|
||||||
|
pub fn new(http_client: HttpClient) -> Self {
|
||||||
|
Self {
|
||||||
|
http_client: Arc::new(http_client),
|
||||||
|
current_session: Arc::new(RwLock::new(None)),
|
||||||
|
connectivity_monitor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the connectivity monitor (for marking server reachability)
|
||||||
|
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
|
||||||
|
self.connectivity_monitor = Some(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize and validate server URL.
|
||||||
|
/// Enforces HTTPS — plain HTTP is rejected for security.
|
||||||
|
pub fn normalize_url(url: &str) -> Result<String, 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
|
||||||
|
if !normalized.starts_with("https://") {
|
||||||
|
normalized = format!("https://{}", normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash
|
||||||
|
if normalized.ends_with('/') {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to server and get server info
|
||||||
|
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
|
||||||
|
let normalized_url = Self::normalize_url(server_url)?;
|
||||||
|
let endpoint = format!("{}/System/Info/Public", normalized_url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
|
|
||||||
|
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
|
||||||
|
Ok(info) => {
|
||||||
|
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ServerInfo {
|
||||||
|
name: info.server_name,
|
||||||
|
version: info.version,
|
||||||
|
id: info.id,
|
||||||
|
normalized_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[AuthManager] Failed to connect to server: {}", e);
|
||||||
|
|
||||||
|
// Mark server as unreachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_unreachable(Some(e.clone())).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate by username and password
|
||||||
|
pub async fn login(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
let url = Self::normalize_url(server_url)?;
|
||||||
|
let endpoint = format!("{}/Users/AuthenticateByName", url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Authenticating user: {}", username);
|
||||||
|
|
||||||
|
// Build auth header for login request
|
||||||
|
let auth_header = HttpClient::build_auth_header(None, device_id);
|
||||||
|
|
||||||
|
// Build request manually for custom headers
|
||||||
|
let request = self.http_client.client.post(&endpoint)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"Username": username,
|
||||||
|
"Pw": password,
|
||||||
|
}))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Use retry logic
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| format!("Login request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_response: AuthenticateByNameResponse = response.json().await
|
||||||
|
.map_err(|e| format!("Failed to parse login response: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = User {
|
||||||
|
id: auth_response.user.id,
|
||||||
|
name: auth_response.user.name,
|
||||||
|
server_id: auth_response.user.server_id,
|
||||||
|
primary_image_tag: auth_response.user.primary_image_tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AuthResult {
|
||||||
|
user,
|
||||||
|
access_token: auth_response.access_token,
|
||||||
|
server_id: auth_response.server_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify current session by fetching user info
|
||||||
|
pub async fn verify_session(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
user_id: &str,
|
||||||
|
access_token: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let url = Self::normalize_url(server_url)?;
|
||||||
|
let endpoint = format!("{}/Users/{}", url, user_id);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Verifying session for user: {}", user_id);
|
||||||
|
|
||||||
|
// Build auth header
|
||||||
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
|
// Build request manually for custom headers
|
||||||
|
let request = self.http_client.client.get(&endpoint)
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Use retry logic
|
||||||
|
let response = self.http_client.request_with_retry(request).await
|
||||||
|
.map_err(|e| {
|
||||||
|
log::warn!("[AuthManager] Session verification failed: {}", e);
|
||||||
|
format!("Session verification failed: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
|
||||||
|
// Mark server as unreachable for auth errors
|
||||||
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||||
|
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(format!("HTTP {}: {}", status, error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user_response: JellyfinUser = response.json().await
|
||||||
|
.map_err(|e| format!("Failed to parse user response: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
|
||||||
|
|
||||||
|
// Mark server as reachable
|
||||||
|
if let Some(monitor) = &self.connectivity_monitor {
|
||||||
|
let monitor = monitor.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(User {
|
||||||
|
id: user_response.id,
|
||||||
|
name: user_response.name,
|
||||||
|
server_id: user_response.server_id,
|
||||||
|
primary_image_tag: user_response.primary_image_tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout (call Jellyfin logout endpoint)
|
||||||
|
pub async fn logout(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
device_id: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let url = Self::normalize_url(server_url)?;
|
||||||
|
let endpoint = format!("{}/Sessions/Logout", url);
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Logging out");
|
||||||
|
|
||||||
|
// Build auth header
|
||||||
|
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
let request = self.http_client.client.post(&endpoint)
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||||
|
|
||||||
|
// Don't retry logout - if it fails, we'll still clear local state
|
||||||
|
match self.http_client.client.execute(request).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.status().is_success() {
|
||||||
|
log::info!("[AuthManager] Logout successful");
|
||||||
|
} else {
|
||||||
|
log::warn!("[AuthManager] Logout request failed: {}", response.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[AuthManager] Logout request failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session
|
||||||
|
pub async fn get_session(&self) -> Option<Session> {
|
||||||
|
self.current_session.read().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set current session
|
||||||
|
pub async fn set_session(&self, session: Option<Session>) {
|
||||||
|
*self.current_session.write().await = session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Test URL normalization - adds https:// when missing
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_adds_https() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("jellyfin.example.com").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("192.168.1.100:8096").unwrap(),
|
||||||
|
"https://192.168.1.100:8096"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - preserves existing https
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_preserves_https() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("https://jellyfin.example.com").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]
|
||||||
|
fn test_normalize_url_removes_trailing_slash() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("https://jellyfin.example.com/").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url("jellyfin.example.com/").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - trims whitespace
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_trims_whitespace() {
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url(" jellyfin.example.com ").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AuthManager::normalize_url(" https://jellyfin.example.com/ ").unwrap(),
|
||||||
|
"https://jellyfin.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test URL normalization - real world case
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_url_real_world_case() {
|
||||||
|
let input = "jellyfin.tourolle.paris";
|
||||||
|
let normalized = AuthManager::normalize_url(input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
|
||||||
|
assert!(normalized.starts_with("https://"));
|
||||||
|
assert!(!normalized.ends_with('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::{AuthManager, User};
|
||||||
|
|
||||||
|
// Verification interval (5 minutes)
|
||||||
|
const VERIFICATION_INTERVAL_MS: u64 = 300000;
|
||||||
|
|
||||||
|
/// Session verification result event emitted to frontend
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase", tag = "type")]
|
||||||
|
pub enum SessionVerificationEvent {
|
||||||
|
Verified { user: User },
|
||||||
|
NeedsReauth { reason: String },
|
||||||
|
NetworkError { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background session verifier
|
||||||
|
pub struct SessionVerifier {
|
||||||
|
auth_manager: Arc<AuthManager>,
|
||||||
|
is_running: Arc<AtomicBool>,
|
||||||
|
device_id: String,
|
||||||
|
app_handle: Option<AppHandle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionVerifier {
|
||||||
|
/// Create a new session verifier
|
||||||
|
pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
|
||||||
|
Self {
|
||||||
|
auth_manager,
|
||||||
|
is_running: Arc::new(AtomicBool::new(false)),
|
||||||
|
device_id,
|
||||||
|
app_handle: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the Tauri app handle for event emission
|
||||||
|
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
|
||||||
|
self.app_handle = Some(app_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start periodic session verification
|
||||||
|
pub async fn start(&self) {
|
||||||
|
if self.is_running.swap(true, Ordering::SeqCst) {
|
||||||
|
log::info!("[SessionVerifier] Already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[SessionVerifier] Starting background verification");
|
||||||
|
|
||||||
|
let auth_manager = Arc::clone(&self.auth_manager);
|
||||||
|
let is_running = Arc::clone(&self.is_running);
|
||||||
|
let device_id = self.device_id.clone();
|
||||||
|
let app_handle = self.app_handle.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Initial verification after short delay
|
||||||
|
tokio::time::sleep(Duration::from_millis(2000)).await;
|
||||||
|
|
||||||
|
while is_running.load(Ordering::SeqCst) {
|
||||||
|
// Get current session
|
||||||
|
let session = auth_manager.get_session().await;
|
||||||
|
|
||||||
|
if let Some(session) = session {
|
||||||
|
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
|
||||||
|
|
||||||
|
// Verify the session
|
||||||
|
match auth_manager
|
||||||
|
.verify_session(
|
||||||
|
&session.server_url,
|
||||||
|
&session.user_id,
|
||||||
|
&session.access_token,
|
||||||
|
&device_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(user) => {
|
||||||
|
log::info!("[SessionVerifier] Session verified successfully");
|
||||||
|
|
||||||
|
// Emit success event
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::Verified { user };
|
||||||
|
if let Err(e) = app.emit("auth:session-verified", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session as verified
|
||||||
|
let mut updated_session = session;
|
||||||
|
updated_session.verified = true;
|
||||||
|
updated_session.needs_reauth = false;
|
||||||
|
auth_manager.set_session(Some(updated_session)).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[SessionVerifier] Verification failed: {}", e);
|
||||||
|
|
||||||
|
// Classify error
|
||||||
|
let is_auth_error = e.contains("401") || e.contains("403");
|
||||||
|
let is_network_error = e.contains("network")
|
||||||
|
|| e.contains("timeout")
|
||||||
|
|| e.contains("connection")
|
||||||
|
|| e.contains("DNS");
|
||||||
|
|
||||||
|
if is_auth_error {
|
||||||
|
// Token is invalid - need re-authentication
|
||||||
|
log::warn!("[SessionVerifier] Session requires re-authentication");
|
||||||
|
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::NeedsReauth {
|
||||||
|
reason: "Session expired".to_string(),
|
||||||
|
};
|
||||||
|
if let Err(e) = app.emit("auth:needs-reauth", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
let mut updated_session = session;
|
||||||
|
updated_session.verified = false;
|
||||||
|
updated_session.needs_reauth = true;
|
||||||
|
auth_manager.set_session(Some(updated_session)).await;
|
||||||
|
} else if is_network_error {
|
||||||
|
// Network error - keep using cached session
|
||||||
|
log::info!("[SessionVerifier] Network error during verification, keeping cached session");
|
||||||
|
|
||||||
|
if let Some(app) = &app_handle {
|
||||||
|
let event = SessionVerificationEvent::NetworkError {
|
||||||
|
message: e.clone(),
|
||||||
|
};
|
||||||
|
if let Err(e) = app.emit("auth:network-error", event) {
|
||||||
|
log::error!("[SessionVerifier] Failed to emit event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown error - log but don't invalidate
|
||||||
|
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for next verification
|
||||||
|
tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[SessionVerifier] Stopped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop periodic verification
|
||||||
|
pub fn stop(&self) {
|
||||||
|
log::info!("[SessionVerifier] Stopping background verification");
|
||||||
|
self.is_running.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
|
||||||
|
|
||||||
|
/// Wrapper for AuthManager to manage in Tauri state
|
||||||
|
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
|
||||||
|
|
||||||
|
/// Wrapper for SessionVerifier to manage in Tauri state
|
||||||
|
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
|
||||||
|
|
||||||
|
/// Initialize the auth manager (call on app startup)
|
||||||
|
/// Restores session from storage if available
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_initialize(
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
||||||
|
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
|
||||||
|
) -> Result<Option<Session>, String> {
|
||||||
|
// First check if we already have a session in memory
|
||||||
|
if let Some(session) = auth_manager.0.get_session().await {
|
||||||
|
return Ok(Some(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to restore session from storage
|
||||||
|
log::info!("[AuthManager] Restoring session from storage...");
|
||||||
|
|
||||||
|
// Use the existing storage_get_active_session function
|
||||||
|
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
|
||||||
|
Ok(Some(session)) => session,
|
||||||
|
Ok(None) => {
|
||||||
|
log::info!("[AuthManager] No active session in storage");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("[AuthManager] Failed to get active session: {}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create session object from active session with normalized URL
|
||||||
|
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url)?;
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
user_id: active_session.user_id,
|
||||||
|
username: active_session.username,
|
||||||
|
server_id: active_session.server_id,
|
||||||
|
server_url: normalized_url,
|
||||||
|
server_name: active_session.server_name,
|
||||||
|
access_token: active_session.access_token,
|
||||||
|
verified: false, // Will be verified in background
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in AuthManager
|
||||||
|
auth_manager.0.set_session(Some(session.clone())).await;
|
||||||
|
|
||||||
|
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
|
||||||
|
Ok(Some(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a Jellyfin server and get server info
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_connect_to_server(
|
||||||
|
server_url: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<ServerInfo, String> {
|
||||||
|
auth_manager.0.connect_to_server(&server_url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login with username and password
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_login(
|
||||||
|
server_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
|
||||||
|
|
||||||
|
// Create session from auth result with normalized URL
|
||||||
|
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url)?;
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
user_id: result.user.id.clone(),
|
||||||
|
username: result.user.name.clone(),
|
||||||
|
server_id: result.server_id.clone(),
|
||||||
|
server_url: normalized_url,
|
||||||
|
server_name: String::new(), // Will be set by frontend
|
||||||
|
access_token: result.access_token.clone(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth_manager.0.set_session(Some(session)).await;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify current session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_verify_session(
|
||||||
|
server_url: String,
|
||||||
|
user_id: String,
|
||||||
|
access_token: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[AuthCommands] Session verification failed: {}", e);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_logout(
|
||||||
|
server_url: String,
|
||||||
|
access_token: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Stop session verification
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
drop(verifier_guard);
|
||||||
|
|
||||||
|
// Call Jellyfin logout endpoint
|
||||||
|
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
|
||||||
|
|
||||||
|
// Clear session
|
||||||
|
auth_manager.0.set_session(None).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current session
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_get_session(
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<Option<Session>, String> {
|
||||||
|
Ok(auth_manager.0.get_session().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set current session (for restoration from storage)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_set_session(
|
||||||
|
session: Option<Session>,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Normalize the server URL if session is provided
|
||||||
|
let normalized_session = match session {
|
||||||
|
Some(mut s) => {
|
||||||
|
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url)?;
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth_manager.0.set_session(normalized_session).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start background session verification
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_start_verification(
|
||||||
|
device_id: String,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
|
||||||
|
// Stop existing verifier if any
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get AuthManager Arc
|
||||||
|
let manager = auth_manager.0.clone();
|
||||||
|
|
||||||
|
// Create new verifier
|
||||||
|
let mut verifier = SessionVerifier::new(manager, device_id);
|
||||||
|
verifier.set_app_handle(app_handle);
|
||||||
|
verifier.start().await;
|
||||||
|
|
||||||
|
*verifier_guard = Some(verifier);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop background session verification
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_stop_verification(
|
||||||
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut verifier_guard = session_verifier.0.lock().await;
|
||||||
|
|
||||||
|
if let Some(verifier) = verifier_guard.take() {
|
||||||
|
verifier.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-authenticate with password (when session expired)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn auth_reauthenticate(
|
||||||
|
password: String,
|
||||||
|
device_id: String,
|
||||||
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
|
) -> Result<AuthResult, String> {
|
||||||
|
// Get current session to extract server_url and username
|
||||||
|
let session = auth_manager.0.get_session().await
|
||||||
|
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
|
||||||
|
|
||||||
|
// Re-login with stored credentials
|
||||||
|
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
|
||||||
|
|
||||||
|
// Update session with new token
|
||||||
|
let updated_session = Session {
|
||||||
|
user_id: result.user.id.clone(),
|
||||||
|
username: result.user.name.clone(),
|
||||||
|
server_id: result.server_id.clone(),
|
||||||
|
server_url: session.server_url,
|
||||||
|
server_name: session.server_name,
|
||||||
|
access_token: result.access_token.clone(),
|
||||||
|
verified: true,
|
||||||
|
needs_reauth: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth_manager.0.set_session(Some(updated_session)).await;
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
|
||||||
|
|
||||||
|
/// Wrapper for ConnectivityMonitor managed state
|
||||||
|
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
|
||||||
|
|
||||||
|
/// Check if the server is currently reachable
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_check_server(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
Ok(monitor.check_reachability().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the server URL and trigger an immediate check
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_set_server_url(
|
||||||
|
url: String,
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.set_server_url(url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current connectivity status
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_get_status(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<ConnectivityStatus, String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
Ok(monitor.get_status().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start monitoring connectivity with adaptive polling
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_start_monitoring(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.start_monitoring().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop monitoring connectivity
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_stop_monitoring(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.stop_monitoring();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the server as reachable (called after successful API calls)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_mark_reachable(
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.mark_reachable().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the server as unreachable (called after failed API calls)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn connectivity_mark_unreachable(
|
||||||
|
error: Option<String>,
|
||||||
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let monitor = state.0.lock().await;
|
||||||
|
monitor.mark_unreachable(error).await;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Tauri commands for unit conversions and formatting
|
||||||
|
//!
|
||||||
|
//! These commands expose conversion utilities to the frontend,
|
||||||
|
//! allowing centralized conversion logic in Rust.
|
||||||
|
|
||||||
|
use crate::utils::conversions::{
|
||||||
|
format_time, format_time_long, calculate_progress,
|
||||||
|
ticks_to_seconds, percent_to_volume,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Format time in seconds to MM:SS display string
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `seconds` - Time in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Formatted string like "3:45" or "12:09"
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn format_time_seconds(seconds: f64) -> String {
|
||||||
|
format_time(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format time in seconds to HH:MM:SS or MM:SS display string
|
||||||
|
///
|
||||||
|
/// Automatically chooses format based on duration:
|
||||||
|
/// - Less than 1 hour: Returns MM:SS format
|
||||||
|
/// - 1 hour or more: Returns HH:MM:SS format
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `seconds` - Time in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Formatted string like "1:23:45" or "3:45"
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn format_time_seconds_long(seconds: f64) -> String {
|
||||||
|
format_time_long(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Jellyfin ticks to seconds
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Time in seconds
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
||||||
|
ticks_to_seconds(ticks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate progress percentage from position and duration
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `position` - Current position in seconds
|
||||||
|
/// * `duration` - Total duration in seconds
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Progress as percentage (0.0 to 100.0)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
||||||
|
calculate_progress(position, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `percent` - Volume as percentage (0 to 100)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Normalized volume (0.0 to 1.0)
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||