Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635afe2d1e |
@@ -1,18 +0,0 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.claude
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.vscode
|
||||
.idea
|
||||
target
|
||||
*.apk
|
||||
*.aab
|
||||
*.log
|
||||
coverage
|
||||
src-tauri/gen
|
||||
src-tauri/target
|
||||
@@ -1,138 +0,0 @@
|
||||
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
|
||||
@@ -1,340 +0,0 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (e.g., v1.0.0)'
|
||||
required: false
|
||||
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: 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"
|
||||
@@ -1,152 +0,0 @@
|
||||
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
|
||||
@@ -1,175 +0,0 @@
|
||||
name: Requirement Traceability Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
name: Validate Requirement Traces
|
||||
runs-on: 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
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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
|
||||
@@ -1,121 +0,0 @@
|
||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANDROID_HOME=/opt/android-sdk \
|
||||
NDK_VERSION=27.0.11902837 \
|
||||
SDK_VERSION=34 \
|
||||
RUST_BACKTRACE=1 \
|
||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
||||
CARGO_HOME=/root/.cargo
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Build essentials
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
ca-certificates \
|
||||
unzip \
|
||||
# JDK for Android
|
||||
openjdk-17-jdk-headless \
|
||||
# Android build tools
|
||||
android-sdk-platform-tools \
|
||||
# Additional development tools
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libclang-dev \
|
||||
llvm-dev \
|
||||
# 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/"]
|
||||
@@ -1,88 +0,0 @@
|
||||
# JellyTau Builder Image
|
||||
# Pre-built image with all dependencies for building and testing
|
||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANDROID_HOME=/opt/android-sdk \
|
||||
NDK_VERSION=27.0.11902837 \
|
||||
SDK_VERSION=36 \
|
||||
BUILD_TOOLS_VERSION=35.0.0 \
|
||||
RUST_BACKTRACE=1 \
|
||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
||||
CARGO_HOME=/root/.cargo
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
ca-certificates \
|
||||
unzip \
|
||||
jq \
|
||||
openjdk-17-jdk-headless \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libclang-dev \
|
||||
llvm-dev \
|
||||
# Tauri Linux desktop dependencies (needed for `cargo test` on the host target)
|
||||
libglib2.0-dev \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libjavascriptcoregtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
librsvg2-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
# mpv player library (linked via libmpv-sys)
|
||||
libmpv-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js 20.x from NodeSource
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Bun
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Install Rust using rustup
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
|
||||
. $HOME/.cargo/env && \
|
||||
rustup target add aarch64-linux-android && \
|
||||
rustup target add armv7-linux-androideabi && \
|
||||
rustup target add x86_64-linux-android && \
|
||||
rustup component add rustfmt clippy
|
||||
|
||||
# Setup Android SDK
|
||||
RUN mkdir -p $ANDROID_HOME && \
|
||||
mkdir -p /root/.android && \
|
||||
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
|
||||
echo 'count=0' >> /root/.android/repositories.cfg
|
||||
|
||||
# Download and setup Android Command Line Tools
|
||||
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
|
||||
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
|
||||
rm /tmp/cmdline-tools.zip && \
|
||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
||||
|
||||
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
|
||||
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
|
||||
|
||||
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
|
||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
"platform-tools" \
|
||||
"platforms;android-$SDK_VERSION" \
|
||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||
"ndk;$NDK_VERSION" \
|
||||
--channel=0 2>&1 | grep -v "Warning" || true
|
||||
|
||||
# Set NDK environment variable
|
||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
@@ -1,57 +0,0 @@
|
||||
# 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
|
||||
@@ -1,62 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Test service - runs tests only
|
||||
test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: test
|
||||
container_name: jellytau-test
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
command: bash -c "bun 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:
|
||||
@@ -1,571 +0,0 @@
|
||||
# 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` | - | `()` |
|
||||
@@ -1,647 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,163 +0,0 @@
|
||||
# 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
|
||||
@@ -1,132 +0,0 @@
|
||||
# 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 }
|
||||
}));
|
||||
}
|
||||
```
|
||||
@@ -1,485 +0,0 @@
|
||||
# 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())
|
||||
}
|
||||
```
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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');
|
||||
```
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,614 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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
|
||||
@@ -1,206 +0,0 @@
|
||||
# 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
|
||||
@@ -1,347 +0,0 @@
|
||||
# Build & Release Workflow
|
||||
|
||||
This document explains the automated build and release process for JellyTau.
|
||||
|
||||
## Overview
|
||||
|
||||
The CI/CD pipeline automatically:
|
||||
1. ✅ Runs all tests (frontend + Rust)
|
||||
2. ✅ Builds Linux binaries (AppImage + DEB)
|
||||
3. ✅ Builds Android APK and AAB
|
||||
4. ✅ Creates releases with artifacts
|
||||
5. ✅ Tags releases with version numbers
|
||||
|
||||
## Workflow Triggers
|
||||
|
||||
### Automatic Trigger
|
||||
When you push a version tag:
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
The workflow automatically:
|
||||
1. Runs tests
|
||||
2. Builds both platforms
|
||||
3. Creates a GitHub release with artifacts
|
||||
4. Tags it as release/prerelease based on version
|
||||
|
||||
### Manual Trigger
|
||||
In Gitea Actions UI:
|
||||
1. Go to **Actions** tab
|
||||
2. Click **Build & Release** workflow
|
||||
3. Click **Run workflow**
|
||||
4. Optionally specify a version
|
||||
5. Workflow runs without creating a release
|
||||
|
||||
## Version Tagging
|
||||
|
||||
### Format
|
||||
Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
||||
|
||||
Examples:
|
||||
- `v1.0.0` - Release version
|
||||
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
|
||||
- `v1.0.0-beta` - Beta version (marked as prerelease)
|
||||
- `v0.1.0-alpha` - Alpha version (marked as prerelease)
|
||||
|
||||
### Creating a Release
|
||||
|
||||
```bash
|
||||
# Create and push a version tag
|
||||
git tag v1.0.0 -m "Release version 1.0.0"
|
||||
git push origin v1.0.0
|
||||
|
||||
# Or create from main branch
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0" main
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
### Release Status
|
||||
|
||||
Versions containing `rc`, `beta`, or `alpha` are marked as **prerelease**:
|
||||
```bash
|
||||
git tag v1.0.0-rc1 # ⚠️ Prerelease
|
||||
git tag v1.0.0-beta # ⚠️ Prerelease
|
||||
git tag v1.0.0-alpha # ⚠️ Prerelease
|
||||
git tag v1.0.0 # ✅ Full release
|
||||
```
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### 1. Test Phase
|
||||
Runs on all tags and manual triggers:
|
||||
- Frontend tests (`vitest`)
|
||||
- Rust tests (`cargo test`)
|
||||
- TypeScript type checking
|
||||
|
||||
**Failure:** Stops workflow, no build/release
|
||||
|
||||
### 2. Build Linux Phase
|
||||
Runs after tests pass:
|
||||
- Installs system dependencies
|
||||
- Builds with Tauri
|
||||
- Generates:
|
||||
- **AppImage** - Universal Linux binary
|
||||
- **DEB** - Debian/Ubuntu package
|
||||
|
||||
**Output:** `artifacts/linux/`
|
||||
|
||||
### 3. Build Android Phase
|
||||
Runs in parallel with Linux build:
|
||||
- Installs Android SDK/NDK
|
||||
- Configures Rust for Android targets
|
||||
- Builds with Tauri
|
||||
- Generates:
|
||||
- **APK** - Android app package (installable)
|
||||
- **AAB** - Android App Bundle (for Play Store)
|
||||
|
||||
**Output:** `artifacts/android/`
|
||||
|
||||
### 4. Create Release Phase
|
||||
Runs after both builds succeed (only on version tags):
|
||||
- Prepares release notes
|
||||
- Downloads build artifacts
|
||||
- Creates GitHub/Gitea release
|
||||
- Uploads all artifacts
|
||||
- Tags as prerelease if applicable
|
||||
|
||||
## Artifacts
|
||||
|
||||
### Linux Artifacts
|
||||
|
||||
#### AppImage
|
||||
- **File:** `jellytau_*.AppImage`
|
||||
- **Size:** ~100-150 MB
|
||||
- **Use:** Run directly on any Linux distro
|
||||
- **Installation:**
|
||||
```bash
|
||||
chmod +x jellytau_*.AppImage
|
||||
./jellytau_*.AppImage
|
||||
```
|
||||
|
||||
#### DEB Package
|
||||
- **File:** `jellytau_*.deb`
|
||||
- **Size:** ~80-120 MB
|
||||
- **Use:** Install on Debian/Ubuntu/similar
|
||||
- **Installation:**
|
||||
```bash
|
||||
sudo dpkg -i jellytau_*.deb
|
||||
jellytau
|
||||
```
|
||||
|
||||
### Android Artifacts
|
||||
|
||||
#### APK
|
||||
- **File:** `jellytau-release.apk`
|
||||
- **Size:** ~60-100 MB
|
||||
- **Use:** Direct installation on Android devices
|
||||
- **Installation:**
|
||||
```bash
|
||||
adb install jellytau-release.apk
|
||||
# Or sideload via file manager
|
||||
```
|
||||
|
||||
#### AAB (Android App Bundle)
|
||||
- **File:** `jellytau-release.aab`
|
||||
- **Size:** ~50-90 MB
|
||||
- **Use:** Upload to Google Play Console
|
||||
- **Note:** Cannot be installed directly; for Play Store distribution
|
||||
|
||||
## Release Notes
|
||||
|
||||
Release notes are automatically generated with:
|
||||
- Version number
|
||||
- Download links
|
||||
- Installation instructions
|
||||
- System requirements
|
||||
- Known issues link
|
||||
- Changelog reference
|
||||
|
||||
## Build Matrix
|
||||
|
||||
| Platform | OS | Architecture | Format |
|
||||
|----------|----|----|--------|
|
||||
| **Linux** | Any | x86_64 | AppImage, DEB |
|
||||
| **Android** | 8.0+ | arm64, armv7, x86_64 | APK, AAB |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails During Test Phase
|
||||
1. Check test output in Gitea Actions
|
||||
2. Run tests locally: `bun run test` and `bun run test:rust`
|
||||
3. Fix failing tests
|
||||
4. Create new tag with fixed code
|
||||
|
||||
### Linux Build Fails
|
||||
1. Check system dependencies installed
|
||||
2. Verify Tauri configuration
|
||||
3. Check cargo dependencies
|
||||
4. Clear cache: Delete `.cargo` and `target/` directories
|
||||
|
||||
### Android Build Fails
|
||||
1. Check Android SDK/NDK setup
|
||||
2. Verify Java 17 is installed
|
||||
3. Check Rust Android targets: `rustup target list`
|
||||
4. Clear cache and rebuild
|
||||
|
||||
### Release Not Created
|
||||
1. Tag must start with `v` (e.g., `v1.0.0`)
|
||||
2. Tests must pass
|
||||
3. Both builds must succeed
|
||||
4. Check workflow logs for errors
|
||||
|
||||
## GitHub Release vs Gitea
|
||||
|
||||
The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:
|
||||
|
||||
1. Workflow creates artifacts
|
||||
2. Artifacts are available in Actions artifacts
|
||||
3. Download and manually create Gitea release, or
|
||||
4. Set up Gitea API integration to auto-publish
|
||||
|
||||
## Customization
|
||||
|
||||
### Change Release Notes Template
|
||||
|
||||
Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:
|
||||
|
||||
```yaml
|
||||
- name: Prepare release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
# Add your custom release notes format here
|
||||
echo "Custom notes" > release_notes.md
|
||||
```
|
||||
|
||||
### Add New Platforms
|
||||
|
||||
To add macOS or Windows builds:
|
||||
|
||||
1. Add new `build-{platform}` job
|
||||
2. Set appropriate `runs-on` runner
|
||||
3. Add platform-specific dependencies
|
||||
4. Update artifact upload
|
||||
5. Include in `needs: [build-linux, build-android, build-{platform}]`
|
||||
|
||||
### Change Build Targets
|
||||
|
||||
Modify Tauri configuration or add targets:
|
||||
|
||||
```yaml
|
||||
- name: Build for Linux
|
||||
run: |
|
||||
# Add target specification
|
||||
bun run tauri build -- --target x86_64-unknown-linux-gnu
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Status
|
||||
1. Go to **Actions** tab in Gitea
|
||||
2. View **Build & Release** workflow runs
|
||||
3. Click specific run to see logs
|
||||
|
||||
### Notifications
|
||||
Set up notifications for:
|
||||
- Build failures
|
||||
- Release creation
|
||||
- Tag pushes
|
||||
|
||||
## Performance
|
||||
|
||||
### Build Times (Approximate)
|
||||
- Test phase: 5-10 minutes
|
||||
- Linux build: 10-15 minutes
|
||||
- Android build: 15-20 minutes
|
||||
- Total: 30-45 minutes
|
||||
|
||||
### Caching
|
||||
Workflow caches:
|
||||
- Rust dependencies (cargo)
|
||||
- Bun node_modules
|
||||
- Android SDK components
|
||||
|
||||
## Security
|
||||
|
||||
### Secrets
|
||||
The workflow uses:
|
||||
- `GITHUB_TOKEN` - Built-in, no setup needed
|
||||
- No credentials needed for Gitea
|
||||
|
||||
### Verification
|
||||
To verify build integrity:
|
||||
1. Download artifacts
|
||||
2. Verify signatures (if implemented)
|
||||
3. Check file hashes
|
||||
4. Test on target platform
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Versioning
|
||||
1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
||||
2. Tag releases in git
|
||||
3. Update CHANGELOG.md before tagging
|
||||
4. Include release notes in tag message
|
||||
|
||||
### Testing Before Release
|
||||
```bash
|
||||
# Local testing before release
|
||||
bun run test # Frontend tests
|
||||
bun run test:rust # Rust tests
|
||||
bun run check # Type checking
|
||||
bun run tauri build # Local build test
|
||||
```
|
||||
|
||||
### Documentation
|
||||
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
|
||||
2. Update [README.md](../README.md) with new features
|
||||
3. Document breaking changes
|
||||
4. Add migration guide if needed
|
||||
|
||||
## Example Release Workflow
|
||||
|
||||
```bash
|
||||
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
|
||||
vim package.json
|
||||
vim src-tauri/tauri.conf.json
|
||||
|
||||
# 2. Update CHANGELOG
|
||||
vim CHANGELOG.md
|
||||
|
||||
# 3. Commit changes
|
||||
git add .
|
||||
git commit -m "Bump version to v1.0.0"
|
||||
|
||||
# 4. Create annotated tag
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0
|
||||
|
||||
Features:
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
|
||||
Fixes:
|
||||
- Fix 1
|
||||
- Fix 2"
|
||||
|
||||
# 5. Push tag to trigger workflow
|
||||
git push origin v1.0.0
|
||||
|
||||
# 6. Monitor workflow in Gitea Actions
|
||||
# Wait for tests → Linux build → Android build → Release
|
||||
|
||||
# 7. Download artifacts and test
|
||||
# Visit release page and verify downloads
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Tauri Documentation](https://tauri.app/)
|
||||
- [Semantic Versioning](https://semver.org/)
|
||||
- [GitHub Release Best Practices](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
|
||||
- [Android App Bundle](https://developer.android.com/guide/app-bundle)
|
||||
- [AppImage Documentation](https://docs.appimage.org/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-13
|
||||
@@ -1,156 +0,0 @@
|
||||
# Building and Pushing the JellyTau Builder Image
|
||||
|
||||
This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed and running
|
||||
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
|
||||
- Docker registry credentials configured (`docker login`)
|
||||
|
||||
## Building the Builder Image
|
||||
|
||||
### Step 1: Build the Image Locally
|
||||
|
||||
```bash
|
||||
# From the project root
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
```
|
||||
|
||||
This creates a local image with:
|
||||
- All system dependencies
|
||||
- Rust with Android targets
|
||||
- Android SDK and NDK
|
||||
- Node.js and Bun
|
||||
- All build tools pre-installed
|
||||
|
||||
### Step 2: Tag for Your Registry
|
||||
|
||||
Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:
|
||||
|
||||
```bash
|
||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
```
|
||||
|
||||
### Step 3: Login to Your Registry
|
||||
|
||||
If not already logged in:
|
||||
|
||||
```bash
|
||||
docker login gitea.tourolle.paris
|
||||
```
|
||||
|
||||
### Step 4: Push to Registry
|
||||
|
||||
```bash
|
||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
```
|
||||
|
||||
## Complete One-Liner
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
|
||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
|
||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
```
|
||||
|
||||
## Verifying the Build
|
||||
|
||||
Check that the image was pushed successfully:
|
||||
|
||||
```bash
|
||||
# List images in your registry (depends on registry API support)
|
||||
docker search gitea.tourolle.paris/dtourolle/jellytau-builder
|
||||
|
||||
# Or pull and test locally
|
||||
docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
|
||||
```
|
||||
|
||||
## Using in CI/CD
|
||||
|
||||
The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:
|
||||
```yaml
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
```
|
||||
|
||||
Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.
|
||||
|
||||
## Updating the Builder Image
|
||||
|
||||
When dependencies change (new Rust version, Android SDK update, etc.):
|
||||
|
||||
1. Update `Dockerfile.builder` with the new configuration
|
||||
2. Rebuild and push with a new tag:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
|
||||
docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
||||
```
|
||||
|
||||
3. Update the workflow to use the new tag:
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
|
||||
```
|
||||
|
||||
## Image Contents
|
||||
|
||||
The builder image includes:
|
||||
|
||||
- **Base OS**: Ubuntu 24.04
|
||||
- **Languages**:
|
||||
- Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
|
||||
- Node.js 20.x
|
||||
- OpenJDK 17 (for Android)
|
||||
- **Tools**:
|
||||
- Bun package manager
|
||||
- Android SDK 34
|
||||
- Android NDK 27.0.11902837
|
||||
- Build essentials (gcc, make, etc.)
|
||||
- Git, curl, wget
|
||||
- libssl, libclang development libraries
|
||||
- **Pre-configured**:
|
||||
- Rust toolchain components (rustfmt, clippy)
|
||||
- Android SDK/NDK environment variables
|
||||
- All paths optimized for building
|
||||
|
||||
## Build Time
|
||||
|
||||
First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK).
|
||||
Subsequent builds are cached and take seconds.
|
||||
|
||||
## Storage
|
||||
|
||||
The built image is approximately **4-5 GB**. Ensure your registry has sufficient storage.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Image not found" in CI
|
||||
- Verify the image name matches exactly in the workflow
|
||||
- Check that the image was successfully pushed: `docker push` output should show successful layers
|
||||
- Ensure Gitea has access to your registry (check network/firewall)
|
||||
|
||||
### Build fails with "command not found"
|
||||
- The image may not have finished pushing. Wait a few moments and retry the CI job.
|
||||
- Check that all layers were pushed successfully in the push output.
|
||||
|
||||
### Registry authentication in CI
|
||||
If your registry requires credentials in CI:
|
||||
1. Create a deploy token in your registry
|
||||
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
|
||||
3. Use in workflow:
|
||||
```yaml
|
||||
- name: Login to Registry
|
||||
run: |
|
||||
docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Docker Build Documentation](https://docs.docker.com/build/)
|
||||
- [Docker Push Documentation](https://docs.docker.com/engine/reference/commandline/push/)
|
||||
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
|
||||
@@ -1,282 +0,0 @@
|
||||
# Docker & CI/CD Setup for JellyTau
|
||||
|
||||
This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.
|
||||
|
||||
## Overview
|
||||
|
||||
The setup includes:
|
||||
- **Dockerfile.builder**: Pre-built image with all dependencies (push to your registry)
|
||||
- **Dockerfile**: Multi-stage build for local testing and building
|
||||
- **docker-compose.yml**: Orchestration for local development and testing
|
||||
- **.gitea/workflows/build-and-test.yml**: Automated CI/CD pipeline using pre-built builder image
|
||||
|
||||
### Quick Start
|
||||
|
||||
**For CI/CD (Gitea Actions)**:
|
||||
1. Build and push builder image (see [build-builder-image.md](build-builder-image.md))
|
||||
2. Push to master branch - workflow runs automatically
|
||||
3. Check Actions tab for results and APK artifacts
|
||||
|
||||
**For Local Testing**:
|
||||
```bash
|
||||
docker-compose run test # Run tests
|
||||
docker-compose run android-build # Build APK
|
||||
docker-compose run dev # Interactive shell
|
||||
```
|
||||
|
||||
## Docker Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 2.0+ (if using docker-compose)
|
||||
- At least 10GB free disk space (for Android SDK and build artifacts)
|
||||
|
||||
### Building the Docker Image
|
||||
|
||||
```bash
|
||||
# Build the complete image
|
||||
docker build -t jellytau:latest .
|
||||
|
||||
# Build specific target
|
||||
docker build -t jellytau:test --target test .
|
||||
docker build -t jellytau:android --target android-build .
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
#### Run Tests Only
|
||||
```bash
|
||||
docker-compose run test
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Install all dependencies
|
||||
2. Run frontend tests (Vitest)
|
||||
3. Run Rust backend tests
|
||||
4. Report results
|
||||
|
||||
#### Build Android APK
|
||||
```bash
|
||||
docker-compose run android-build
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Run tests first (depends on test service)
|
||||
2. If tests pass, build the Android APK
|
||||
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`
|
||||
|
||||
#### Interactive Development
|
||||
```bash
|
||||
docker-compose run dev
|
||||
```
|
||||
|
||||
This starts an interactive shell with all development tools available. From here you can:
|
||||
```bash
|
||||
bun install
|
||||
bun run build
|
||||
bun test
|
||||
bun run tauri android build --apk true
|
||||
```
|
||||
|
||||
#### Run All Services in Sequence
|
||||
```bash
|
||||
docker-compose up --abort-on-container-exit
|
||||
```
|
||||
|
||||
### Extracting Build Artifacts
|
||||
|
||||
After a successful build, APK files are located in:
|
||||
```
|
||||
src-tauri/gen/android/app/build/outputs/apk/
|
||||
```
|
||||
|
||||
Copy to your host machine:
|
||||
```bash
|
||||
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
|
||||
```
|
||||
|
||||
## Gitea Act CI/CD Pipeline
|
||||
|
||||
The `.gitea/workflows/build-and-test.yml` workflow automates:
|
||||
|
||||
**Single Job**: Runs on every push to `master` and PRs
|
||||
- Uses pre-built builder image (no setup time)
|
||||
- Installs project dependencies
|
||||
- Runs frontend tests (Vitest)
|
||||
- Runs Rust backend tests
|
||||
- Builds the frontend
|
||||
- Builds the Android APK
|
||||
- Uploads APK as artifact (30-day retention)
|
||||
|
||||
The workflow skips markdown files to avoid unnecessary builds.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
The workflow runs on:
|
||||
- Push to `master` or `main` branches
|
||||
- Pull requests to `master` or `main` branches
|
||||
- Can be extended with: `workflow_dispatch` for manual triggers
|
||||
|
||||
### Setting Up the Builder Image
|
||||
|
||||
Before using the CI/CD pipeline, you must build and push the builder image:
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
|
||||
# Tag for your registry
|
||||
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
# Push to registry
|
||||
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
```
|
||||
|
||||
See [build-builder-image.md](build-builder-image.md) for detailed instructions.
|
||||
|
||||
### Setting Up Gitea Act
|
||||
|
||||
1. **Ensure builder image is pushed** (see above)
|
||||
|
||||
2. **Push to Gitea repository**:
|
||||
The workflow will automatically trigger on push to `master` or pull requests
|
||||
|
||||
3. **View workflow runs in Gitea UI**:
|
||||
- Navigate to your repository
|
||||
- Go to Actions tab
|
||||
- Click on workflow runs to see logs
|
||||
|
||||
4. **Test locally** (optional):
|
||||
```bash
|
||||
# Install act if needed
|
||||
curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
|
||||
|
||||
# Run locally (requires builder image to be available)
|
||||
./act push --file .gitea/workflows/build-and-test.yml
|
||||
```
|
||||
|
||||
### Customizing the Workflow
|
||||
|
||||
#### Modify Build Triggers
|
||||
Edit `.gitea/workflows/build-and-test.yml` to change when builds run:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop # Add more branches
|
||||
paths:
|
||||
- 'src/**' # Only run if src/ changes
|
||||
- 'src-tauri/**' # Only run if Rust code changes
|
||||
```
|
||||
|
||||
#### Add Notifications
|
||||
Add Slack, Discord, or email notifications on build completion:
|
||||
|
||||
```yaml
|
||||
- name: Notify on success
|
||||
if: success()
|
||||
run: |
|
||||
curl -X POST https://slack-webhook-url...
|
||||
```
|
||||
|
||||
#### Customize APK Upload
|
||||
Modify artifact retention or add to cloud storage:
|
||||
|
||||
```yaml
|
||||
- name: Upload APK to S3
|
||||
uses: actions/s3-sync@v1
|
||||
with:
|
||||
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
|
||||
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
|
||||
aws_bucket: my-apk-bucket
|
||||
source_dir: src-tauri/gen/android/app/build/outputs/apk/
|
||||
```
|
||||
|
||||
## Environment Setup in CI
|
||||
|
||||
### Secret Variables
|
||||
To use secrets in the workflow, set them in Gitea:
|
||||
|
||||
1. Go to Repository Settings → Secrets
|
||||
2. Add secrets like:
|
||||
- `AWS_ACCESS_KEY` for S3 uploads
|
||||
- `SLACK_WEBHOOK_URL` for notifications
|
||||
- `GITHUB_TOKEN` for releases (pre-configured)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Out of Memory During Build
|
||||
Android builds are memory-intensive. If you get OOM errors:
|
||||
|
||||
```bash
|
||||
# Limit memory in docker-compose
|
||||
services:
|
||||
android-build:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 6G
|
||||
```
|
||||
|
||||
Or increase Docker's memory allocation in Docker Desktop settings.
|
||||
|
||||
### Android SDK Download Timeout
|
||||
If downloads timeout, increase timeout or download manually:
|
||||
|
||||
```bash
|
||||
# In container, with longer timeout
|
||||
timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
|
||||
```
|
||||
|
||||
### Rust Compilation Errors
|
||||
Make sure Rust is updated:
|
||||
|
||||
```bash
|
||||
rustup update
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
```
|
||||
|
||||
### Cache Issues
|
||||
Clear Docker cache and rebuild:
|
||||
|
||||
```bash
|
||||
docker-compose down -v # Remove volumes
|
||||
docker system prune # Clean up dangling images
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Cache Reuse**: Both Docker and Gitea Act cache dependencies across runs
|
||||
2. **Parallel Steps**: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
|
||||
3. **Incremental Builds**: Rust and Node caches persist between runs
|
||||
4. **Docker Buildkit**: Enable for faster builds:
|
||||
```bash
|
||||
DOCKER_BUILDKIT=1 docker build .
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
|
||||
- NDK is downloaded from official Google servers (verified via HTTPS)
|
||||
- No credentials are stored in the Dockerfile
|
||||
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
|
||||
- Lock dependency versions in `Cargo.toml` and `package.json`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Test locally with `docker-compose up`
|
||||
2. Push to your Gitea repository
|
||||
3. Monitor workflow runs in the Actions tab
|
||||
4. Configure secrets in repository settings for production builds
|
||||
5. Set up artifact retention policies (currently 30 days)
|
||||
|
||||
## References
|
||||
|
||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
||||
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
||||
- [Android Build Tools](https://developer.android.com/studio/command-line)
|
||||
- [Tauri Android Guide](https://tauri.app/v1/guides/building/android)
|
||||
@@ -1,300 +0,0 @@
|
||||
# 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! 🚀
|
||||
@@ -1,454 +0,0 @@
|
||||
# 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
|
||||
@@ -1,288 +0,0 @@
|
||||
# Requirement Traceability CI/CD Pipeline
|
||||
|
||||
This document explains the automated requirement traceability validation system for JellyTau.
|
||||
|
||||
## Overview
|
||||
|
||||
The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:
|
||||
- ✅ Requirements are implemented with clear traceability
|
||||
- ✅ No requirement coverage regressions
|
||||
- ✅ Code changes are linked to specific requirements
|
||||
- ✅ Quality metrics are tracked over time
|
||||
|
||||
## Gitea Actions Workflows
|
||||
|
||||
Two workflows are configured in `.gitea/workflows/`:
|
||||
|
||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
||||
Gitea-native workflow with:
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request
|
||||
|
||||
### 2. `traceability.yml` (Alternative)
|
||||
GitHub-compatible workflow with additional features:
|
||||
- Pull request comments with coverage stats
|
||||
- GitHub-specific integrations
|
||||
|
||||
## What Gets Validated
|
||||
|
||||
### 1. Trace Extraction
|
||||
```bash
|
||||
bun run traces:json > traces-report.json
|
||||
```
|
||||
Extracts all TRACES comments from:
|
||||
- TypeScript files (`src/**/*.ts`)
|
||||
- Svelte components (`src/**/*.svelte`)
|
||||
- Rust code (`src-tauri/src/**/*.rs`)
|
||||
- Test files
|
||||
|
||||
### 2. Coverage Thresholds
|
||||
The workflow checks:
|
||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
||||
- **Requirements by type:**
|
||||
- UR (User): 23+ of 39
|
||||
- IR (Integration): 5+ of 24
|
||||
- DR (Development): 28+ of 48
|
||||
- JA (Jellyfin API): 0+ of 3
|
||||
|
||||
If coverage drops below threshold, the workflow **fails** and blocks merge.
|
||||
|
||||
### 3. Modified File Checking
|
||||
On pull requests, the workflow:
|
||||
1. Detects all changed TypeScript/Svelte/Rust files
|
||||
2. Warns if new/modified files lack TRACES comments
|
||||
3. Suggests the TRACES format for missing comments
|
||||
|
||||
## How to Add Traces to New Code
|
||||
|
||||
When you add new code or modify existing code, include TRACES comments:
|
||||
|
||||
### TypeScript/Svelte Example
|
||||
```typescript
|
||||
// TRACES: UR-005, UR-026 | DR-029
|
||||
export function handlePlayback() {
|
||||
// Implementation...
|
||||
}
|
||||
```
|
||||
|
||||
### Rust Example
|
||||
```rust
|
||||
/// TRACES: UR-005 | DR-001
|
||||
pub fn player_state_changed(state: PlayerState) {
|
||||
// Implementation...
|
||||
}
|
||||
```
|
||||
|
||||
### Test Example
|
||||
```rust
|
||||
// TRACES: UR-005 | DR-001 | UT-026, UT-027
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// Tests...
|
||||
}
|
||||
```
|
||||
|
||||
## TRACES Format
|
||||
|
||||
```
|
||||
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
|
||||
```
|
||||
|
||||
- `UR-###` - User Requirements (features users see)
|
||||
- `IR-###` - Integration Requirements (API/platform integration)
|
||||
- `DR-###` - Development Requirements (internal architecture)
|
||||
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)
|
||||
|
||||
**Examples:**
|
||||
- `// TRACES: UR-005` - Single requirement
|
||||
- `// TRACES: UR-005, UR-026` - Multiple of same type
|
||||
- `// TRACES: UR-005 | DR-029` - Multiple types
|
||||
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex
|
||||
|
||||
## Workflow Behavior
|
||||
|
||||
### On Push to Main Branch
|
||||
1. ✅ Extracts all traces from code
|
||||
2. ✅ Validates coverage is >= 50%
|
||||
3. ✅ Generates full traceability report
|
||||
4. ✅ Saves report as artifact
|
||||
|
||||
### On Pull Request
|
||||
1. ✅ Extracts all traces
|
||||
2. ✅ Validates coverage >= 50%
|
||||
3. ✅ Checks modified files for TRACES
|
||||
4. ✅ Warns if new code lacks TRACES
|
||||
5. ✅ Suggests proper format
|
||||
6. ✅ Generates report artifact
|
||||
|
||||
### Failure Scenarios
|
||||
The workflow **fails** (blocks merge) if:
|
||||
- Coverage drops below 50%
|
||||
- JSON extraction fails
|
||||
- Invalid trace format
|
||||
|
||||
The workflow **warns** (but doesn't block) if:
|
||||
- New files lack TRACES comments
|
||||
- Coverage drops (but still above threshold)
|
||||
|
||||
## Viewing Reports
|
||||
|
||||
### In Gitea Actions UI
|
||||
1. Go to **Actions** tab
|
||||
2. Click the **Traceability Validation** workflow run
|
||||
3. Download **traceability-reports** artifact
|
||||
4. View:
|
||||
- `traces-report.json` - Raw trace data
|
||||
- `docs/traceability.md` - Formatted report
|
||||
|
||||
### Locally
|
||||
```bash
|
||||
# Extract current traces
|
||||
bun run traces:json | jq '.byType'
|
||||
|
||||
# Generate full report
|
||||
bun run traces:markdown
|
||||
cat docs/traceability.md
|
||||
```
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
### Current Status
|
||||
- Overall: 51% (56/114)
|
||||
- UR: 59% (23/39)
|
||||
- IR: 21% (5/24)
|
||||
- DR: 58% (28/48)
|
||||
- JA: 0% (0/3)
|
||||
|
||||
### Targets
|
||||
- **Short term** (Sprint): Maintain ≥50% overall
|
||||
- **Medium term** (Month): Reach 70% overall coverage
|
||||
- **Long term** (Release): Reach 90% coverage with focus on:
|
||||
- IR requirements (API clients)
|
||||
- JA requirements (Jellyfin API endpoints)
|
||||
- Remaining UR/DR requirements
|
||||
|
||||
## Improving Coverage
|
||||
|
||||
### For Missing User Requirements (UR)
|
||||
1. Review [README.md](../README.md) for unimplemented features
|
||||
2. Add TRACES to code that implements them
|
||||
3. Focus on high-priority features (High/Medium priority)
|
||||
|
||||
### For Missing Integration Requirements (IR)
|
||||
1. Add TRACES to Jellyfin API client methods
|
||||
2. Add TRACES to platform-specific backends (Android/Linux)
|
||||
3. Link to corresponding Jellyfin API endpoints
|
||||
|
||||
### For Missing Development Requirements (DR)
|
||||
1. Add TRACES to UI components in `src/lib/components/`
|
||||
2. Add TRACES to composables in `src/lib/composables/`
|
||||
3. Add TRACES to player backend in `src-tauri/src/player/`
|
||||
|
||||
### For Jellyfin API Requirements (JA)
|
||||
1. Add TRACES to Jellyfin API wrapper methods
|
||||
2. Document which endpoints map to which requirements
|
||||
3. Link to Jellyfin API documentation
|
||||
|
||||
## Example PR Checklist
|
||||
|
||||
When submitting a pull request:
|
||||
|
||||
- [ ] All new code has TRACES comments linking to requirements
|
||||
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
|
||||
- [ ] Workflow passes (coverage ≥ 50%)
|
||||
- [ ] No coverage regressions
|
||||
- [ ] Artifact traceability report was generated
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Coverage below minimum threshold"
|
||||
**Problem:** Workflow fails with coverage < 50%
|
||||
|
||||
**Solution:**
|
||||
1. Run `bun run traces:json` locally
|
||||
2. Check which requirements are traced
|
||||
3. Add TRACES to untraced code sections
|
||||
4. Re-run extraction to verify
|
||||
|
||||
### "New files without TRACES"
|
||||
**Problem:** Workflow warns about new files lacking TRACES
|
||||
|
||||
**Solution:**
|
||||
1. Add TRACES comments to all new code
|
||||
2. Format: `// TRACES: UR-001 | DR-002`
|
||||
3. Map code to specific requirements from README.md
|
||||
4. Re-push
|
||||
|
||||
### "Invalid JSON format"
|
||||
**Problem:** Trace extraction produces invalid JSON
|
||||
|
||||
**Solution:**
|
||||
1. Check for malformed TRACES comments
|
||||
2. Run locally: `bun run traces:json`
|
||||
3. Look for parsing errors
|
||||
4. Fix and retry
|
||||
|
||||
## Integration with Development
|
||||
|
||||
### Before Committing
|
||||
```bash
|
||||
# Check your traces
|
||||
bun run traces:json | jq '.byType'
|
||||
|
||||
# Regenerate report
|
||||
bun run traces:markdown
|
||||
|
||||
# Verify traces syntax
|
||||
grep "TRACES:" src/**/*.ts src/**/*.rs
|
||||
```
|
||||
|
||||
### In Your IDE
|
||||
Add a file watcher to regenerate traces on save:
|
||||
```json
|
||||
{
|
||||
"fileWatcher.watchPatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte",
|
||||
"src-tauri/src/**/*.rs"
|
||||
],
|
||||
"fileWatcher.command": "bun run traces:markdown"
|
||||
}
|
||||
```
|
||||
|
||||
### Git Hooks
|
||||
Add a pre-push hook to validate traces:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-push
|
||||
bun run traces:json > /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Invalid TRACES format"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Extract Traces Script](../scripts/README.md#extract-tracests)
|
||||
- [Requirements Specification](../README.md#requirements-specification)
|
||||
- [Traceability Matrix](./traceability.md)
|
||||
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check this document
|
||||
2. Review example traces in `src/lib/stores/`
|
||||
3. Check existing TRACES comments for format
|
||||
4. Review workflow logs in Gitea Actions
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-02-13
|
||||
@@ -1,212 +0,0 @@
|
||||
# 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!
|
||||
@@ -1,907 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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
|
||||
@@ -1,376 +0,0 @@
|
||||
# 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/)
|
||||
@@ -1,105 +0,0 @@
|
||||
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();
|
||||
@@ -1,53 +0,0 @@
|
||||
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`);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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();
|
||||
@@ -1,116 +0,0 @@
|
||||
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();
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "0.14.0",
|
||||
"notes": "\nVideo on the desktop is played by mpv, on Linux and now on Windows, and the\nbuilt-in web player is gone from every platform. Windows gets real audio\nplayback too.\n\n### 🔒 Security\n\n- **A track title can no longer run a command on Linux.** mpv was handed stream\n URLs and downloaded-file paths as a single command string, in which `;` starts\n a new command — so a file whose title tag carried one could run a program\n when it played. Every mpv command now passes its arguments separately.\n (DR-298)\n- **mpv checks the server's certificate.** It did not by default, and the\n addresses it opens carry your login token, so anyone able to intercept the\n connection could read it. It also no longer hands a failed address to\n youtube-dl. (DR-299)\n- **The app's web page can no longer reach the network.** It needed that only\n for the web video player; everything now goes through the backend, so the\n permission was just a way out for anything injected into the page.\n\n### ✨ Features\n\n- **mpv plays all video on Linux and Windows.** On Windows it draws into the app\n window under the controls; on Linux it no longer needs the experimental\n switch. Video still arrives transcoded to h264 for now — asking the server for\n the original file is the next step. (DR-235, DR-237)\n- **Windows plays audio through mpv**, so volume, the equalizer, volume\n normalization and gapless playback now work there. The installer ships\n mpv's LGPL-licensed library and its licence text. (DR-237)\n\n### 🐛 Fixes\n\n- **Subtitles and audio-track switching work in desktop video.** mpv now loads\n the subtitle list and switches subtitles and audio tracks itself; before this\n they did nothing once mpv drew the picture. (DR-023, DR-024)\n- **Downloaded songs with a space in their title play offline on Linux.**\n (DR-298)\n- **A queued download on Windows keeps the path it was saved under**, rather\n than having its separators rewritten. (DR-211)\n\n### 🧹 Removed\n\n- **The built-in web video player and the \"Native Video\" setting.** There is\n nothing left to switch between: every platform plays video natively.\n (DR-235)",
|
||||
"pub_date": "2026-09-25T10:07:47Z",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaW1OUkUwMmdMTnRzMm1HcFlpcUtyVU5VanZZQTBCS0NuWmRiaExvL3lWcDAwWEswR2tpN0FoZnR2bkJLVFlIeGVLUXR0V20yeUdQeVhNSGtpVnNodHdvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMzI4ODkzCWZpbGU6SmVsbHlUYXVfMC4xNC4wX2FtZDY0LkFwcEltYWdlCjVESjY4ZDZadDBZckFidE1tOERsNWtIeVhJQjArOW1yNGZvQUgwdWx6NU5lcWRLNUo0dkhUazNhQVRWUkJSM0kzWHFkelpwRmNVKyt0VFE3STBxVEJnPT0K",
|
||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.14.0/JellyTau_0.14.0_amd64.AppImage"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaWhSN3Z5b0YvQVMzRWxlWnNUT2I4WmUvelpBOFhYMTRQUWE2bUN5V3NBZGFQWXBSZjJ0MVdiczJOZE1YSGsybkptQnp6MG0rZUJoQjNCUkd3cVZpOGcwPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkwMzI5NTk5CWZpbGU6SmVsbHlUYXVfMC4xNC4wX3g2NC1zZXR1cC5leGUKZ2xFYVZQU05wRVBKSElBUlgxcWVWRVZMM0x1VnFKbVRWc2YrSlBGOUZQOTBTNFBrdy9KU0F0eXRyWTllVFFkeUI4VGZjUFN6bWdaMy9xb0swZDdNQVE9PQo=",
|
||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.14.0/JellyTau_0.14.0_x64-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/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/"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build and push the JellyTau builder Docker image to your registry
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY_HOST="${REGISTRY_HOST:-gitea.tourolle.paris}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-dtourolle}"
|
||||
IMAGE_NAME="jellytau-builder"
|
||||
IMAGE_TAG="${1:-latest}"
|
||||
FULL_IMAGE_NAME="${REGISTRY_HOST}/${REGISTRY_USER}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
echo "🐳 Building JellyTau Builder Image"
|
||||
echo "=================================="
|
||||
echo "Registry: $REGISTRY_HOST"
|
||||
echo "User: $REGISTRY_USER"
|
||||
echo "Image: $FULL_IMAGE_NAME"
|
||||
echo ""
|
||||
|
||||
# Step 1: Build locally
|
||||
echo "🔨 Building Docker image locally..."
|
||||
docker build -f Dockerfile.builder -t ${IMAGE_NAME}:${IMAGE_TAG} .
|
||||
|
||||
# Step 2: Tag for registry
|
||||
echo "🏷️ Tagging for registry..."
|
||||
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ${FULL_IMAGE_NAME}
|
||||
|
||||
# Step 3: Login to registry (if not already logged in)
|
||||
echo "🔐 Checking registry authentication..."
|
||||
if ! docker info | grep -q "Username"; then
|
||||
echo "Not authenticated to Docker. Logging in to ${REGISTRY_HOST}..."
|
||||
docker login ${REGISTRY_HOST}
|
||||
fi
|
||||
|
||||
# Step 4: Push to registry
|
||||
echo "📤 Pushing image to registry..."
|
||||
docker push ${FULL_IMAGE_NAME}
|
||||
|
||||
echo ""
|
||||
echo "✅ Successfully built and pushed: ${FULL_IMAGE_NAME}"
|
||||
echo ""
|
||||
echo "Update your workflow to use:"
|
||||
echo " container:"
|
||||
echo " image: ${FULL_IMAGE_NAME}"
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/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!"
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/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!"
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,285 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Extract TRACES from source code and generate requirement mapping
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/extract-traces.ts
|
||||
* bun run scripts/extract-traces.ts --format json
|
||||
* bun run scripts/extract-traces.ts --format markdown > docs/traceability.md
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
interface TraceEntry {
|
||||
file: string;
|
||||
line: number;
|
||||
context: string;
|
||||
requirements: string[];
|
||||
}
|
||||
|
||||
interface RequirementMapping {
|
||||
[reqId: string]: TraceEntry[];
|
||||
}
|
||||
|
||||
interface TracesData {
|
||||
timestamp: string;
|
||||
totalFiles: number;
|
||||
totalTraces: number;
|
||||
requirements: RequirementMapping;
|
||||
byType: {
|
||||
UR: string[];
|
||||
IR: string[];
|
||||
DR: string[];
|
||||
JA: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// 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`
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/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 ""
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/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!"
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run frontend tests only
|
||||
|
||||
set -e
|
||||
|
||||
echo "📦 Running frontend tests..."
|
||||
bun run test "$@"
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run Rust tests only
|
||||
|
||||
set -e
|
||||
|
||||
echo "🦀 Running Rust tests..."
|
||||
cd src-tauri
|
||||
cargo test "$@"
|
||||
cd ..
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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
|
||||
@@ -1,72 +0,0 @@
|
||||
[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"
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# ⚠️ 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!
|
||||
@@ -1,103 +0,0 @@
|
||||
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")
|
||||
@@ -1,285 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,226 +0,0 @@
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:path:default"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 974 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,411 +0,0 @@
|
||||
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('/'));
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
//! 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
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Device identification commands
|
||||
//!
|
||||
//! Handles persistent device ID generation and retrieval for Jellyfin server communication.
|
||||
//! TRACES: UR-009 | DR-011
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::info;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::commands::storage::DatabaseWrapper;
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
/// Get or create the device ID.
|
||||
/// Device ID is a UUID v4 that persists across app restarts.
|
||||
/// On first call, generates and stores a new UUID.
|
||||
/// On subsequent calls, retrieves the stored UUID.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(String)` - The device ID (UUID v4)
|
||||
/// - `Err(String)` - If database operation fails
|
||||
///
|
||||
/// TRACES: UR-009 | DR-011
|
||||
#[tauri::command]
|
||||
#[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");
|
||||
}
|
||||
}
|
||||