Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc5a7c21d9 |
@@ -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,124 +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
|
||||
|
||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
||||
# multi-type includeItemTypes query defining a category in the frontend).
|
||||
# See scripts/check-frontend-boundary.sh and
|
||||
# docs/specs/scoped-search-boundary.md.
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
bun run test
|
||||
|
||||
- name: Run Rust tests
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo test
|
||||
cd ..
|
||||
|
||||
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||
# the full signed release APK is built only on tag pushes by build-release.yml
|
||||
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
||||
# too would duplicate a ~15min build and, without the sync step, produced an
|
||||
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
||||
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
||||
# breakage without linking, bundling, or signing.
|
||||
android-check:
|
||||
name: Android Compile Check
|
||||
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
|
||||
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||
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: Cargo check (aarch64-linux-android)
|
||||
run: |
|
||||
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
||||
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
||||
export AR_aarch64_linux_android="$TC/llvm-ar"
|
||||
cd src-tauri
|
||||
cargo check --target aarch64-linux-android --lib
|
||||
@@ -1,369 +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: |
|
||||
# On a tag build, the tag is the single source of truth for the
|
||||
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
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: Pin a monotonic Android versionCode
|
||||
run: |
|
||||
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||
#
|
||||
# Derive an explicit code that is both monotonic in semver order and
|
||||
# always above the 1000 floor already in the field:
|
||||
# code = 1000 + major*10000 + minor*100 + patch
|
||||
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||
# Guard against a malformed/missing component so we never emit code 0.
|
||||
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||
echo "version=$VERSION -> versionCode=$CODE"
|
||||
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||
else
|
||||
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||
fi
|
||||
cat "$PROPS"
|
||||
|
||||
- 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,124 +0,0 @@
|
||||
name: Publish Documentation
|
||||
|
||||
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
|
||||
# API reference with cargo doc, and force-pushes the combined output to the
|
||||
# orphan `gitea-pages` branch that the Gitea Pages server serves.
|
||||
#
|
||||
# The published matrix is regenerated during the build, so it is never stale.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
# Only one docs publish at a time; a newer push supersedes an in-flight run.
|
||||
group: publish-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish-docs:
|
||||
name: Build & publish docs to gitea-pages
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install mdBook
|
||||
run: |
|
||||
set -e
|
||||
MDBOOK_VERSION=v0.4.40
|
||||
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
|
||||
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
|
||||
mdbook --version
|
||||
|
||||
- name: Regenerate traceability matrix (keep published copy current)
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Assemble mdBook sources
|
||||
run: |
|
||||
set -e
|
||||
# mdBook's src is docs/. Drop in the SUMMARY and the generated
|
||||
# intro + API redirect pages (build artifacts, not committed).
|
||||
cp docs-site/SUMMARY.md docs/SUMMARY.md
|
||||
|
||||
cat > docs/README.md <<'EOF'
|
||||
# JellyTau Documentation
|
||||
|
||||
Cross-platform Jellyfin client — business logic in a Rust backend,
|
||||
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
|
||||
|
||||
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
|
||||
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
|
||||
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
|
||||
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
|
||||
|
||||
_This site is published automatically from `master` by the `publish-docs` CI job._
|
||||
EOF
|
||||
|
||||
cat > docs/api-redirect.md <<'EOF'
|
||||
# Rust API Reference
|
||||
|
||||
The full backend API reference is generated by `cargo doc` (rustdoc).
|
||||
|
||||
👉 **[Open the Rust API Reference](api/index.html)**
|
||||
EOF
|
||||
|
||||
- name: Build mdBook site
|
||||
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Build Rust API docs (cargo doc)
|
||||
working-directory: src-tauri
|
||||
# --no-deps keeps it to our own crate (fast, focused); document private
|
||||
# items so internal modules/commands appear.
|
||||
run: |
|
||||
cargo doc --no-deps --document-private-items
|
||||
# The backend modules/commands live in the LIB crate (jellytau_lib);
|
||||
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
|
||||
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
|
||||
> target/doc/index.html
|
||||
|
||||
- name: Assemble published output
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p "$GITHUB_WORKSPACE/site/api"
|
||||
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
|
||||
# Disable Jekyll processing on the pages branch.
|
||||
touch "$GITHUB_WORKSPACE/site/.nojekyll"
|
||||
ls -la "$GITHUB_WORKSPACE/site"
|
||||
|
||||
- name: Push to gitea-pages branch
|
||||
env:
|
||||
# PAT preferred; falls back to the auto-provided token (same pattern
|
||||
# as build-release.yml).
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
|
||||
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
|
||||
|
||||
cd "$GITHUB_WORKSPACE/site"
|
||||
git init -q
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git checkout -q -b gitea-pages
|
||||
git add -A
|
||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
||||
echo "🚀 Force-pushing to gitea-pages"
|
||||
git push -f "$REMOTE" gitea-pages
|
||||
@@ -1,151 +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
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- 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,66 +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
|
||||
|
||||
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
|
||||
/docs/SUMMARY.md
|
||||
/docs/README.md
|
||||
/docs/api-redirect.md
|
||||
/docs-site/book/
|
||||
@@ -1,266 +0,0 @@
|
||||
# JellyTau
|
||||
|
||||
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
||||
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
||||
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
|
||||
`<video>` for transcoded playback) and **Android** (ExoPlayer).
|
||||
|
||||
Package manager is **bun**.
|
||||
|
||||
## Build / Run / Test
|
||||
|
||||
All routine tasks go through `package.json` scripts and helper scripts in
|
||||
`scripts/`:
|
||||
|
||||
```bash
|
||||
bun install # install deps
|
||||
bun run dev # vite dev server (frontend)
|
||||
bun run tauri dev # run the desktop app
|
||||
|
||||
bun run check # svelte-check (types)
|
||||
bun run test # vitest (frontend unit/integration)
|
||||
bun run test:rust # cargo test (scripts/test-rust.sh)
|
||||
bun run test:all # full suite (scripts/test-all.sh)
|
||||
bun run test:e2e # webdriverio e2e
|
||||
|
||||
# Android — canonical entry points (see scripts/):
|
||||
bun run android:build # debug APK
|
||||
bun run android:build:release # release APK
|
||||
bun run android:deploy # install to connected device
|
||||
bun run android:dev # build + deploy
|
||||
bun run android:logs # logcat
|
||||
```
|
||||
|
||||
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
||||
only against the mirror if one exists; the canonical remote is
|
||||
`gitea.tourolle.paris`.
|
||||
|
||||
## Before Committing
|
||||
|
||||
- Frontend: `bun run check` and `bun run test` must pass.
|
||||
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
|
||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
||||
item-type category sets) leaked into the frontend. See below.
|
||||
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
|
||||
comment (see below).
|
||||
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
|
||||
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
|
||||
Never edit the generated `gen/` sources directly.
|
||||
|
||||
## Traceability (TRACES)
|
||||
|
||||
This project practices requirement-driven development: code that implements a
|
||||
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
|
||||
an extraction tool builds the traceability matrix. **When you add or change code
|
||||
that implements a requirement, add/update its TRACES comment.** Internal helpers
|
||||
and requirement-less code stay untraced.
|
||||
|
||||
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
|
||||
|
||||
```rust
|
||||
/// TRACES: UR-005 | DR-001
|
||||
pub enum PlayerState { … }
|
||||
```
|
||||
```typescript
|
||||
// TRACES: UR-005, UR-026 | DR-029
|
||||
export function autoplayNextEpisode() { }
|
||||
```
|
||||
|
||||
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
|
||||
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
|
||||
in [docs/requirements.md](docs/requirements.md); the generated matrix is
|
||||
[docs/traceability.md](docs/traceability.md).
|
||||
|
||||
Tooling:
|
||||
|
||||
```bash
|
||||
bun run traces # extract traces (default format)
|
||||
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
|
||||
bun run traces:markdown # regenerate docs/traceability.md
|
||||
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
|
||||
```
|
||||
|
||||
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
|
||||
GitHub. `traceability-check.yml` fails the build if coverage drops below
|
||||
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
|
||||
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
|
||||
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
|
||||
|
||||
### Traces drive release notes
|
||||
|
||||
Prefer traceability over raw commit subjects when writing release notes for
|
||||
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
|
||||
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
|
||||
release touched.
|
||||
|
||||
```bash
|
||||
bun run release:notes # <latest tag>..HEAD
|
||||
bun run release:notes v0.0.15..HEAD # explicit range
|
||||
```
|
||||
|
||||
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
|
||||
changed files → their `TRACES:` IDs → descriptions in
|
||||
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
|
||||
and **DR/IR** into *Improvements* (deduped, so many commits touching one
|
||||
requirement collapse to one line). It also lists changed files that carry no
|
||||
TRACES so nothing is silently dropped — those still need a manual line. Treat the
|
||||
output as a reviewed draft, not a final changelog.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
|
||||
sessions, downloads, offline cache, playback control. Commands grouped by
|
||||
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
|
||||
`download/`, `offline.rs`, `sessions.rs`, …).
|
||||
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
||||
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
||||
`src/lib/components/`.
|
||||
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
|
||||
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
|
||||
ExoPlayer with a foreground media service + `MediaSessionCompat`.
|
||||
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
|
||||
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
|
||||
|
||||
**Read the architecture docs before making structural changes** — they are the
|
||||
canonical, maintained source; this file only summarizes. See
|
||||
[docs/architecture/README.md](docs/architecture/README.md) and:
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
|
||||
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
|
||||
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
|
||||
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
|
||||
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
|
||||
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
|
||||
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
|
||||
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
|
||||
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
|
||||
|
||||
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
|
||||
and [docs/build-release.md](docs/build-release.md).
|
||||
|
||||
### Core principles (from the architecture docs)
|
||||
|
||||
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
|
||||
Linux, session poller in remote mode) is the **authoritative source** of state
|
||||
— position, pause, seeking, rate, track changes. The Svelte UI, OS
|
||||
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
|
||||
player reports and never determine it.
|
||||
- **Unified player boundary.** UI controls playback *only* through the frontend
|
||||
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
||||
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
|
||||
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
|
||||
commands, so the controller stays the single source of truth in both native
|
||||
and HTML5 modes.
|
||||
- **Reachability from real traffic.** Server online/offline is derived from the
|
||||
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
||||
a side-channel poller. The `/System/Info/Public` probe runs *only while
|
||||
offline*, as a recovery detector.
|
||||
- **Poison-tolerant locking.** Access shared `std::sync` state 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 fails to initialize, the
|
||||
app falls back to a no-op backend and emits `backend-init-failed` rather than
|
||||
crashing.
|
||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
|
||||
## Writing specs
|
||||
|
||||
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
||||
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
|
||||
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
|
||||
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
|
||||
Before accepting a spec, run it past
|
||||
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
|
||||
a spec around "no Rust changes required" — correct layer placement is the goal,
|
||||
not minimal backend churn.
|
||||
|
||||
## Conventions
|
||||
|
||||
### Rust Backend
|
||||
|
||||
- Use `#[tauri::command]` for all IPC handlers.
|
||||
- Prefer `async` commands for I/O-bound work.
|
||||
- Return `Result<T, String>` from commands (the established convention here).
|
||||
- Use `tauri::State<>` for shared state.
|
||||
- Group related commands in domain modules under `commands/`.
|
||||
- Use official Tauri plugins before writing custom native code.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
|
||||
- Define TS types matching the Rust structs; prefer the generated bindings.
|
||||
- Handle IPC errors with try/catch.
|
||||
- Use `@tauri-apps/api/path` for paths (never hardcode).
|
||||
- Use `@tauri-apps/api/event` for backend→frontend events.
|
||||
|
||||
### 🔴 IPC parameter naming (Tauri v2)
|
||||
|
||||
The command **name** must match the Rust function name exactly
|
||||
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
|
||||
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
|
||||
on the frontend:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub async fn cmd(repository_handle: String) { … }
|
||||
```
|
||||
```typescript
|
||||
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
|
||||
```
|
||||
|
||||
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
|
||||
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
|
||||
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
||||
|
||||
### Events
|
||||
|
||||
- Backend events use **kebab-case** names (`download-event`, `search-event`).
|
||||
- Emit from Rust via `emit(...)`; consume on the frontend via
|
||||
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
|
||||
|
||||
### Security
|
||||
|
||||
- Declare minimum permissions in `src-tauri/capabilities/`.
|
||||
- Keep the CSP restrictive in `tauri.conf.json`.
|
||||
- Validate all inputs in Rust command handlers.
|
||||
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
|
||||
asking the user first.
|
||||
|
||||
## Gotchas (hard-won)
|
||||
|
||||
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
|
||||
player or hold a lock — it deadlocks. On Android, bind a locked
|
||||
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
||||
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
||||
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
|
||||
(it flips to HTML5 mode and breaks Android seek).
|
||||
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
|
||||
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
||||
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
||||
Don't loop `startDownload` from the frontend.
|
||||
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
|
||||
file changes may be another session — check `git diff` before "repairing".
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cd src-tauri && cargo test
|
||||
cd src-tauri && cargo test test_name # single test
|
||||
|
||||
# Frontend
|
||||
bun run test
|
||||
bun run test:coverage
|
||||
|
||||
# Tauri IPC param-naming integration tests (guard the camelCase rule):
|
||||
bun run test -- tauriIntegration.test.ts
|
||||
```
|
||||
@@ -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,60 +0,0 @@
|
||||
<h1 align="center">
|
||||
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
||||
JellyTau
|
||||
</h1>
|
||||
|
||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||
|
||||
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,39 +0,0 @@
|
||||
# Summary
|
||||
|
||||
[Introduction](README.md)
|
||||
|
||||
# Requirements & Traceability
|
||||
|
||||
- [Requirements Specification](requirements.md)
|
||||
- [Traceability Matrix](traceability.md)
|
||||
- [Traceability CI](traceability-ci.md)
|
||||
- [Traces Quick Reference](traces-quick-ref.md)
|
||||
|
||||
# Architecture
|
||||
|
||||
- [Overview](architecture/README.md)
|
||||
- [Rust Backend](architecture/01-rust-backend.md)
|
||||
- [Svelte Frontend](architecture/02-svelte-frontend.md)
|
||||
- [Data Flow](architecture/03-data-flow.md)
|
||||
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
|
||||
- [Platform Backends](architecture/05-platform-backends.md)
|
||||
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
|
||||
- [Connectivity](architecture/07-connectivity.md)
|
||||
- [Database Design](architecture/08-database-design.md)
|
||||
- [Security](architecture/09-security.md)
|
||||
|
||||
# UX & Specs
|
||||
|
||||
- [UX Flows](ux-flows.md)
|
||||
- [Video Background Audio](specs/video-background-audio.md)
|
||||
|
||||
# Build & Release
|
||||
|
||||
- [Build & Release](build-release.md)
|
||||
- [Release Checklist](release-checklist.md)
|
||||
- [Docker](build/docker.md)
|
||||
- [Builder Image](build/build-builder-image.md)
|
||||
|
||||
---
|
||||
|
||||
[Rust API Reference (rustdoc)](api-redirect.md)
|
||||
@@ -1,25 +0,0 @@
|
||||
# mdBook config for the published JellyTau documentation site.
|
||||
# The book's `src` is the repo `docs/` directory (see [build] below); this file
|
||||
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
|
||||
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
|
||||
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
|
||||
[book]
|
||||
title = "JellyTau Documentation"
|
||||
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
|
||||
authors = ["Duncan Tourolle"]
|
||||
language = "en"
|
||||
# Sources live in the repo docs/ dir (one level up from this book root).
|
||||
src = "../docs"
|
||||
|
||||
[output.html]
|
||||
default-theme = "navy"
|
||||
preferred-dark-theme = "navy"
|
||||
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
|
||||
|
||||
[output.html.fold]
|
||||
enable = true
|
||||
level = 1
|
||||
|
||||
[output.html.search]
|
||||
enable = true
|
||||
@@ -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,659 +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:**
|
||||
|
||||
Ordinal content (where position carries meaning) is always a list. Everything
|
||||
else honours the user's persisted grid/list preference — see
|
||||
[ux-flows.md §5A.2](../ux-flows.md).
|
||||
|
||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||
|--------------|-----------|----------------|----------------|
|
||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Artists | User preference | Yes | `LibraryGrid` |
|
||||
| Albums | User preference | Yes | `LibraryGrid` |
|
||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||
|
||||
**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 view mode:**
|
||||
|
||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||
prop controls whether the toggle buttons appear in the page header; the grid
|
||||
itself always follows the stored preference.
|
||||
|
||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||
preference. No caller ever passed it, so it was removed — pages that were
|
||||
documented as "forced grid" have in practice always honoured the toggle.
|
||||
|
||||
## 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,529 +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.
|
||||
|
||||
## HTML5 Video Adapter (webview-rendered video)
|
||||
|
||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||
`src-tauri/src/commands/player/timers.rs`
|
||||
|
||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||
the real player, living outside Rust's reach.
|
||||
|
||||
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
||||
authority:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Webview["Webview"]
|
||||
Video["HTML5 <video> / HLS.js"]
|
||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||||
end
|
||||
subgraph Backend["Rust"]
|
||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||||
Controller["PlayerController"]
|
||||
Emitter["TauriEventEmitter"]
|
||||
end
|
||||
subgraph Frontend["Frontend"]
|
||||
Events["playerEvents.ts"]
|
||||
Store["player store"]
|
||||
end
|
||||
|
||||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
||||
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
||||
another event source feeding the existing pipeline.
|
||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||||
60fps RAF loop.
|
||||
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
||||
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
||||
("frontend only displays state and invokes commands") for the video path.
|
||||
|
||||
## 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,210 +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`).
|
||||
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
|
||||
- **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)
|
||||
├── player/ # Unified player boundary (frontend)
|
||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
||||
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
||||
├── 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
|
||||
|
Before Width: | Height: | Size: 142 KiB |
@@ -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,540 +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 |
|
||||
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
|
||||
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
|
||||
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
|
||||
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
|
||||
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
|
||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Broken (toggle does not gate the listing; see issue #10) |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Planned |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Planned |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | 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 |
|
||||
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
|
||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
|
||||
### 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 |
|
||||
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | 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 |
|
||||
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
|
||||
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
|
||||
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
|
||||
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
|
||||
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
|
||||
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
|
||||
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
|
||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Partial (gate implemented and unit-tested; defeated upstream by DR-079 and by the repository fallback in DR-080) |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Broken (`isConnected` ANDs in `navigator.onLine`, so a live link with an unreachable server never enters offline listing) |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Broken (`has_content()` cache-hit test in `HybridRepository::get_items`/`parallel_race` falls through to the server on an intentionally empty result) |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Planned |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Planned |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Planned |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Planned |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Planned |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | 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, DR-059 |
|
||||
| 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 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052 |
|
||||
| UR-041 | IR-026 | DR-053 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Pending |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Pending |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Pending |
|
||||
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | 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 |
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Pending |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | 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,68 +0,0 @@
|
||||
# Spec review checklist
|
||||
|
||||
Run a spec past this before accepting it. It exists because JellyTau's
|
||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||
This checklist is the human gate. The CI check
|
||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||
signature — it does **not** replace this.
|
||||
|
||||
Copy the boxes into the review comment (or the PR) and tick them.
|
||||
|
||||
## Boundary (the one that bites)
|
||||
|
||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||
*logic*, not files. A spec without this section is not ready to review.
|
||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||
query-shaping rules, business rules, reachability/sync policy. If the
|
||||
frontend names a *set* of item types to define a category, that is a leak —
|
||||
it belongs behind an opaque enum the backend expands.
|
||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||
the decision of its value.
|
||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||
frontend. Borderline defaults to Rust.
|
||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||
is **not** over-corrected into the backend. The rule targets category
|
||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||
list page.
|
||||
|
||||
## IPC contract
|
||||
|
||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||
|
||||
## Requirements & traceability
|
||||
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||
pointing here — no two specs silently contradicting.
|
||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||
|
||||
---
|
||||
|
||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||
assignment first. Every other section can be negotiated; that one is the whole
|
||||
reason this file exists.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Spec: <feature name>
|
||||
|
||||
<!--
|
||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||
comments as you go. The section that matters most for this project is
|
||||
"Layer assignment" — read its comment before writing it.
|
||||
|
||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||
-->
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||
|
||||
## Motivation
|
||||
|
||||
<!-- Why now. The problem being solved. -->
|
||||
|
||||
## Layer assignment
|
||||
|
||||
<!--
|
||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||
do NOT reframe it as "how little backend work can we get away with."
|
||||
|
||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||
navigation, view/order preferences, input handling.
|
||||
|
||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||
the frontend — the backend accepting a parameter does not make deciding that
|
||||
parameter's value a presentation concern.
|
||||
|
||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||
yes (and only yes), it is presentation → frontend.
|
||||
|
||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||
means as a set of Jellyfin types) in the frontend because the backend already
|
||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||
at spec time, not in review three features later.
|
||||
-->
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||
|
||||
<!--
|
||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||
-->
|
||||
|
||||
## Design
|
||||
|
||||
<!--
|
||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||
- Command NAME must match the Rust fn name exactly.
|
||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||
- Events are kebab-case.
|
||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||
|
||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||
-->
|
||||
|
||||
## Out of scope
|
||||
|
||||
<!-- What this spec deliberately does NOT do. -->
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- Checkable statements. Include the standard gates: -->
|
||||
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||
|
||||
## TRACES
|
||||
|
||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
<!--
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||
- Anything else non-obvious.
|
||||
-->
|
||||
@@ -1,164 +0,0 @@
|
||||
# Spec: Account menu and global chrome availability
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend only. No Rust changes required.
|
||||
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||
[requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||
account menu anchored to the user's name, and make that menu available on every
|
||||
authenticated non-immersive screen.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||
belongs to the library layout. The user has to guess that account actions live
|
||||
*inside* Library — an unrelated section — and navigate there first.
|
||||
|
||||
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||
same two actions are found two different ways.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The header is not global.** It is defined in
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||
all.
|
||||
|
||||
2. **`routeOwnsLayout`** in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||
flex column. Everything else renders into the root scroller with the root's
|
||||
`BottomUi` below it.
|
||||
|
||||
3. **Bottom nav is Home / Search / Library only**
|
||||
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||
or account entry.
|
||||
|
||||
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||
Settings or Sign out.
|
||||
|
||||
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||
trigger.
|
||||
|
||||
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||
|
||||
7. **`viewMode` is already a persisted store** in
|
||||
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||
`localStorage`). The Display setting is a second view onto it — **no new
|
||||
state, no migration.**
|
||||
|
||||
## Design
|
||||
|
||||
### `AccountMenu` component (DR-075)
|
||||
|
||||
One component used by both breakpoints. Contents in fixed order:
|
||||
|
||||
```
|
||||
Signed in as <name> ← identity block, not interactive
|
||||
<server host>
|
||||
────────────────────────
|
||||
Downloads
|
||||
Settings
|
||||
Display ← grid/list preference
|
||||
────────────────────────
|
||||
Sign out ← destructive, last, after a divider
|
||||
```
|
||||
|
||||
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||
name shows inside the open menu regardless.
|
||||
- **Same items, same order, both platforms.**
|
||||
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||
and focus return to the trigger on close.
|
||||
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||
`aria-expanded` on the trigger.
|
||||
|
||||
"Display" may either navigate to the Settings Display section or expose the
|
||||
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||
preferences and avoids a nested control inside a dropdown.
|
||||
|
||||
### Global chrome (DR-076)
|
||||
|
||||
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||
and `/downloads`.
|
||||
|
||||
The cleanest route is to lift the header out of the library layout into a shared
|
||||
component rendered by the root layout, with the library layout consuming the
|
||||
same component rather than defining its own. **Do not duplicate the markup into
|
||||
each route.**
|
||||
|
||||
Constraints that must survive the change:
|
||||
|
||||
- `/player/*` and `/login` stay chrome-free.
|
||||
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||
already there), but must not double up on chrome.
|
||||
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||
own column. Preserve the scroll containment; a regression here reintroduces
|
||||
the "last row hidden behind the nav" bug called out in those comments.
|
||||
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||
`showBottomNav`) must be unchanged.
|
||||
|
||||
### Display section in Settings (DR-077)
|
||||
|
||||
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||
with the grid/list control bound to the existing `viewMode` store via
|
||||
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||
controls drive the same store, so they stay in sync for free.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the Settings page or reorganising its existing sections.
|
||||
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||
active server but offers no switcher.
|
||||
- Changing the bottom nav's three destinations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||
without first navigating into Library.
|
||||
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||
- [ ] The username/avatar opens the menu; it is a real button with
|
||||
`aria-expanded`.
|
||||
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||
state + redirects as it does today.
|
||||
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||
immediately reflected by the library page-header toggle (same store).
|
||||
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||
visibility on any route.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||
logout handler.
|
||||
- Display setting: writes through to the `viewMode` store and persists.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||
`UR-054, UR-029 | DR-077`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The layout shell is subtle and the existing comments record real bugs that
|
||||
were fixed there. Read them before restructuring.
|
||||
- Another session may be active in this repo, including in
|
||||
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||
unexpected changes, and expect to coordinate on that file.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Spec: Downloads as a browsable offline library
|
||||
|
||||
**Status:** Draft — ready to implement
|
||||
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the flat Active/Completed download list with two views under
|
||||
`/downloads`:
|
||||
|
||||
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||
using the *same* browse screens as online (grids, cards, detail pages).
|
||||
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||
showing only in-flight transfers.
|
||||
|
||||
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||
pages, a device total, and the remove confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||
offline" and "what is downloading" are different questions crammed into one flat
|
||||
list. Browsing offline should feel exactly like browsing online.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The offline repository is already a browsable tree.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||
filter to downloaded content via CTEs. This is the data source for
|
||||
Downloaded; **do not build a new query layer.**
|
||||
|
||||
2. **The client cannot reach it independently.**
|
||||
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||
`repositoryGetItems` always goes through the **hybrid** repository
|
||||
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||
server. There is no "offline only" browse path exposed. This is the one real
|
||||
backend gap (DR-082).
|
||||
|
||||
3. **Downloads page is a flat two-tab list.**
|
||||
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||
|
||||
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||
given. Downloaded browse is those components with an offline-scoped source.
|
||||
|
||||
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||
and falls through to the server. The offline-only browse path (DR-082) must
|
||||
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||
downloaded here."
|
||||
|
||||
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||
|
||||
## Design
|
||||
|
||||
### View split (DR-081)
|
||||
|
||||
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||
default. Transfers shows a count/badge only while transfers are active.
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||
does not start downloads.
|
||||
|
||||
### Offline-scoped browse source (DR-082, DR-083)
|
||||
|
||||
Add an explicit offline-only browse path so Downloaded never merges server
|
||||
results and never depends on reachability. Two viable shapes — pick per the
|
||||
codebase, do not do both:
|
||||
|
||||
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||
`_libraries`) that calls the offline repository directly, with a matching
|
||||
client method; or
|
||||
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||
|
||||
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||
fallthrough), and the path is available while the server is reachable (a user
|
||||
online still wants to browse their downloads).
|
||||
|
||||
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||
source. Omit libraries and containers with no downloaded content. Badge
|
||||
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||
available at item / album / season / series level and removes a container from
|
||||
the browse when its last downloaded child goes.
|
||||
|
||||
### Transfers view (DR-084)
|
||||
|
||||
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||
this view — they appear in Downloaded. Empty state points at the library.
|
||||
|
||||
### Disk usage (DR-085, UR-056)
|
||||
|
||||
- **Source the bytes from the download manager** — it writes the files and can
|
||||
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||
This is display + aggregation, **not** new tracking.
|
||||
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||
it everywhere.
|
||||
- **Surface it in familiar places:** a secondary size label on the card and
|
||||
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||
that reconciles with the listed sum; a reclaim figure in the remove
|
||||
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||
that governs the *online offline-fallback* library; this is the dedicated
|
||||
Downloads surface. They should be consistent but are separate work.
|
||||
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||
on that behaviour here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||
the same grids/cards/detail pages as online browsing.
|
||||
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||
through to the server.
|
||||
- [ ] Play from Downloaded plays the local file.
|
||||
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||
transfers move to Downloaded.
|
||||
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||
shown and reconciles with the sum; remove states the reclaim amount.
|
||||
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||
`cargo clippy` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Repository client: the offline-only browse path returns downloaded content and
|
||||
its containers, and an empty result does **not** trigger server fallthrough.
|
||||
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||
partial/full container badging.
|
||||
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||
total reconciles with listed items.
|
||||
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The offline repository already does the hard part. The main work is a clean
|
||||
offline-only client path and reusing the library components — resist
|
||||
rebuilding browse UI.
|
||||
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||
@@ -1,309 +0,0 @@
|
||||
# Spec: Remove Jellyfin-specific models from the frontend
|
||||
|
||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||
> classification* and *time units* no longer speak Jellyfin:
|
||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||
> - User-facing type badge → `kindLabel()`.
|
||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||
>
|
||||
> **Deferred (tracked, not done):**
|
||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||
|
||||
**Status:** Partially implemented (catalog surface); see banner.
|
||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||
architecture/boundary migration.
|
||||
**Supersedes / revises:** none. Extends the boundary work started in
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||
*whole media model*.
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||
comparisons across 23 files** and two frontend utility modules
|
||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||
field conversion in the presentation layer.
|
||||
|
||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||
names — remains in `src/`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two concrete problems, one strategic:
|
||||
|
||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||
is domain knowledge in the wrong layer, 36 files deep.
|
||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||
should have already resolved.
|
||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||
shut.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||
|
||||
**Borderline calls, resolved:**
|
||||
|
||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||
subset of the new domain model, not a special case.
|
||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||
`item_type`. Only the Jellyfin item-type strings move.
|
||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||
self-contained — deferred to a late phase, not phase 1.
|
||||
|
||||
## Design
|
||||
|
||||
### Single canonical model, one location, isolated mappings
|
||||
|
||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||
whole app:
|
||||
|
||||
```
|
||||
src-tauri/src/domain/
|
||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||
mod.rs re-exports
|
||||
| tauri-specta (export_typescript_bindings test)
|
||||
v
|
||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||
```
|
||||
|
||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||
projection of it. There is no second hand-written copy in either language, so it
|
||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||
not duplication.
|
||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||
touches the domain type. Adding a second provider later means a new
|
||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||
repository-specific.
|
||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||
translation instead of copying `item_type` through.
|
||||
|
||||
### The domain model (Rust)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
Track, Album, Artist, Playlist, // music
|
||||
Movie, Series, Season, Episode, // video
|
||||
Person, // cast/crew
|
||||
Channel, Folder, // containers/live
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: MediaKind, // was: type: String
|
||||
pub is_folder: bool,
|
||||
pub server_id: String,
|
||||
|
||||
// Times in milliseconds — NEVER ticks.
|
||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||
|
||||
// Image as a resolved identifier the frontend turns into a URL via the
|
||||
// existing image command — no raw Jellyfin tag semantics leak.
|
||||
pub image_id: Option<String>, // was: primary_image_tag
|
||||
pub backdrop_image_ids: Option<Vec<String>>,
|
||||
|
||||
pub overview: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub production_year: Option<i32>,
|
||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||
pub community_rating: Option<f64>,
|
||||
pub official_rating: Option<String>,
|
||||
|
||||
// Relationships — already neutral, kept.
|
||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||
|
||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||
pub track_number: Option<i32>, // was: index_number
|
||||
pub disc_number: Option<i32>, // was: parent_index_number
|
||||
|
||||
pub user_data: Option<UserData>,
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
```
|
||||
|
||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||
copies `item_type` straight across — is enriched into the single, tested place
|
||||
that:
|
||||
|
||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||
`Other` variant — **decide at implementation; must not panic.**
|
||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||
|
||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||
|
||||
### 🔴 The `search-event` / dual-payload rule applies again
|
||||
|
||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||
and any other event payloads — emits the new domain shape. Both sides of a
|
||||
twice-delivered result must match (same rule as
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||
event definitions before declaring a phase done.
|
||||
|
||||
### Frontend after
|
||||
|
||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||
they exist, which are not Jellyfin-specific).
|
||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||
|
||||
## Phased migration
|
||||
|
||||
This is too large and too collision-prone for one change. Phases are independently
|
||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||
|
||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||
wire shape is a superset of today's, so the frontend still compiles and
|
||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||
new ones default).
|
||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||
`bun run check` is the driver.
|
||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||
outside tests.
|
||||
|
||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||
- Changing any user-visible behaviour, layout, or copy.
|
||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||
demands it.
|
||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||
item type (Rust test with a garbage type string).
|
||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||
same navigation, offline included.
|
||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||
critical surface —
|
||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||
- unknown type string → safe fallback, no panic;
|
||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||
|
||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||
branching matches the old `type`-based branching for a representative mix.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||
frontend files and the core Rust types. A parallel Claude session in any media
|
||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||
the disruptive flip. Resist the urge to skip it.
|
||||
- IPC camelCase rules apply to the new enums/structs
|
||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||
`bindings.ts`, never hand-edit.
|
||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||
Layer assignment table above is the load-bearing section.
|
||||
@@ -1,235 +0,0 @@
|
||||
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||
commands, no schema changes, no UI additions.
|
||||
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||
media does not work."*
|
||||
|
||||
## Summary
|
||||
|
||||
Offline, a library page is supposed to show **only media on the device**, with a
|
||||
"Show all server media" toggle that additionally reveals the cached server
|
||||
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||
does not gate the listing — every server item still appears. This spec fixes
|
||||
that with two independent changes; either one alone leaves the bug visible.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code. **The feature is built and mostly correct — this is a
|
||||
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||
command, or the SQL gate.
|
||||
|
||||
1. **The SQL gate works and is unit-tested.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||
true; with it false, only downloaded/local rows return. Guarded by
|
||||
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||
query.**
|
||||
|
||||
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||
`set_show_server_catalog` command
|
||||
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||
`include = connected || showCatalog` and pushes it on every change.
|
||||
|
||||
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||
them.
|
||||
|
||||
4. **`MediaCard` already greys and queues.**
|
||||
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||
it.
|
||||
|
||||
## The two defects
|
||||
|
||||
### Defect A — offline is never actually entered (DR-079)
|
||||
|
||||
`pushCatalogVisibility` keys off `isConnected`, but
|
||||
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||
|
||||
```ts
|
||||
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||
```
|
||||
|
||||
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||
stays true, so `include` stays true, so the backend keeps returning the full
|
||||
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||
gets a chance to gate anything.
|
||||
|
||||
This is the primary cause: it explains why the filter looks dead rather than
|
||||
merely inverted — the gate never closes.
|
||||
|
||||
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||
|
||||
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||
correctly returns few or zero rows. But
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||
~10 other reads) falls through to the server and returns the full server list —
|
||||
re-defeating the filter even after Defect A is fixed.
|
||||
|
||||
## Design
|
||||
|
||||
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||
|
||||
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||
store:
|
||||
|
||||
```ts
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
```
|
||||
|
||||
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||
immediate recheck (`online`/`offline` listeners already call
|
||||
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||
Leave `isOnline` on the state object and the listeners intact.
|
||||
|
||||
Consider whether the optimistic `isServerReachable: true` startup default
|
||||
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||
choice in a comment.
|
||||
|
||||
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||
anything importing it). Enumerate consumers first:
|
||||
|
||||
```
|
||||
grep -rn "isConnected" src/ | grep -v node_modules
|
||||
```
|
||||
|
||||
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||
trigger. It almost always is — that is the whole point of the reachability model
|
||||
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||
device link in the PR description.
|
||||
|
||||
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||
|
||||
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||
for downloads only and there are none here." The gate flag already encodes intent
|
||||
— reuse it.
|
||||
|
||||
Add a getter beside the existing setter in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||
|
||||
```rust
|
||||
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||
```
|
||||
|
||||
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||
behaviour is unchanged: empty cache still falls through to the server.
|
||||
|
||||
Keep it surgical:
|
||||
|
||||
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||
thread it into `parallel_race` or the other readers, which have no catalog
|
||||
gate and legitimately want the server on an empty cache.
|
||||
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||
while reachable) the method behaves as it does today, including the background
|
||||
cache refresh on a hit.
|
||||
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||
|
||||
### Why both
|
||||
|
||||
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||
downloads present the list still gets padded by the server fallback whenever a
|
||||
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||
never goes false on a live link. Ship them together.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||
- Home-screen and resume queries — already downloads-only.
|
||||
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||
- Any new IPC command, DB column, or settings entry.
|
||||
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||
UX-placement question, tracked separately under UR-051's toggle note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [~] With the server unreachable on a live device link, a library page lists
|
||||
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||
coverage via UT-069 + gate tests).
|
||||
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||
- [x] A library with downloads and a thin cache does not get padded with
|
||||
non-downloaded server items when offline with the toggle off (Defect B —
|
||||
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||
offline (UT-069).
|
||||
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||
while reachable) `get_items` keeps the offline fast-path and background
|
||||
refresh (UT-067 + gate-on fall-through test).
|
||||
- [~] A download queued from a greyed offline card resolves and starts on
|
||||
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||
resume path untouched).
|
||||
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||
touched files).
|
||||
|
||||
## Testing
|
||||
|
||||
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||
|
||||
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||
result as-is and does **not** query the server. Assert via a mock online repo
|
||||
whose `get_items` bumps a call counter that must stay at zero.
|
||||
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||
|
||||
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||
|
||||
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||
unreachable with `navigator.onLine === true`; true when reachable with
|
||||
`navigator.onLine === false`.
|
||||
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||
and pushes to the backend on a change of either input (extend the existing
|
||||
offlineCatalog tests).
|
||||
|
||||
Integration (IT-016, IT-017) are documented as pending in
|
||||
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||
|
||||
New/changed requirement code keeps its `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||
before Fix A — it is the canonical statement of the reachability model this fix
|
||||
restores fidelity to.
|
||||
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||
ordering already holds for the SQL gate today. No new locking.
|
||||
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||
other new rows.
|
||||
@@ -1,273 +0,0 @@
|
||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** Rust + Frontend. **Revises a decision in
|
||||
[scoped-search.md](scoped-search.md).**
|
||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||
architecture/boundary change with **no user-visible behaviour difference**.
|
||||
|
||||
## Why this spec exists
|
||||
|
||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||
|
||||
The offending knowledge lives in
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||
|
||||
```ts
|
||||
const SCOPE_ITEM_TYPES = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
const GROUP_ITEM_TYPES = {
|
||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||
creates: the day the backend starts returning a type the frontend never
|
||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||
drops it from both the query filter and the result buckets, and nothing in the
|
||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||
sources of truth that will drift.
|
||||
|
||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||
baked into a released wire contract.
|
||||
|
||||
### What is *not* a leak (leave it alone)
|
||||
|
||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||
|
||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||
`tv.ts` → `["Series"]`
|
||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||
`PersonDetailView`
|
||||
|
||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||
the line. Do **not** invent a backend enum for every list page — that is
|
||||
over-abstraction, not cleaner separation.
|
||||
|
||||
## The boundary rule after this change
|
||||
|
||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||
> groups. The frontend owns only **group order** (presentation) and
|
||||
> **rendering**.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust owns scope → item-types (query side)
|
||||
|
||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||
|
||||
```rust
|
||||
// repository/types.rs
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope { All, Music, Movies, Tv }
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or None for `All`
|
||||
/// (which must send NO includeItemTypes — see below).
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter().map(String::from).collect()),
|
||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||
online/offline paths (which already honour `include_item_types` — do not touch
|
||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||
|
||||
```rust
|
||||
pub struct SearchOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub search_term: Option<String>,
|
||||
pub scope: Option<SearchScope>, // NEW
|
||||
// include_item_types stays for the single-type list-page callers,
|
||||
// but the SEARCH command derives it from `scope` when scope is set.
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||
non-search `getItems` callers. Document this so a future reader does not send
|
||||
both.
|
||||
|
||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||
|
||||
### Rust owns result bucketing (result side)
|
||||
|
||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||
```
|
||||
|
||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||
to no group are omitted from grouped output (same as today's frontend filter).
|
||||
|
||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||
|
||||
Search returns results **twice**: the command resolves with instant local-cache
|
||||
results, then the merged cache+server union arrives later via the `search-event`
|
||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||
command return value **and** the `search-event` payload must carry
|
||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||
is the single largest part of the change and the easiest to half-do.
|
||||
|
||||
### What the frontend keeps (all pure presentation)
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||
|
||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||
Rust enum (delete the hand-written union).
|
||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||
unit-tested. **Stays exactly as-is.**
|
||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||
presentation.
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||
|
||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||
|
||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||
groups** — no `.type` inspection anywhere:
|
||||
|
||||
```ts
|
||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||
```
|
||||
|
||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||
it as a display hint, not authority.
|
||||
|
||||
### Frontend call-site changes
|
||||
|
||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||
event merge) is preserved.
|
||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||
results.
|
||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||
behaviour; only the type it passes to `SearchResults` changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||
works; only the *source* of the type list moves.
|
||||
- Single concrete-type list pages (see "What is not a leak").
|
||||
- Ranking within or across groups.
|
||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||
[scoped-search.md](scoped-search.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||
in `searchScope.ts` or any search call path. Verify:
|
||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||
`bindings.ts`, not hand-written unions.
|
||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||
groups, same order, same empty/out-of-scope omission, offline included.
|
||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||
shape; no shape flicker between instant and merged results.
|
||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||
committed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`src-tauri`, `cargo test`):
|
||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||
`scope: All` sends no `include_item_types`.
|
||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||
unknown types are dropped; groups come out in canonical order.
|
||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||
|
||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||
compose-over-groups (order + empty-omit, no type inspection).
|
||||
- `searchGroupOrder.test.ts` — unchanged.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||
the wire shape.
|
||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||
§Design "Scope model / Threading scope through the store," which asserted no
|
||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||
banner pointing here — do not leave the two specs contradicting silently.
|
||||
- The IPC camelCase rule applies to the new enums and structs
|
||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||
`tauriIntegration`-style test if a new command is introduced.
|
||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||
types; do not hand-edit it.
|
||||
- **Another Claude session may be active in these same files** (per project
|
||||
memory). `git diff` before repairing anything unexpected; these search files
|
||||
are exactly the ones a parallel session touched.
|
||||
@@ -1,202 +0,0 @@
|
||||
# Spec: Context-scoped search with filter chips and configurable group order
|
||||
|
||||
> ⚠️ **Superseded in part by
|
||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||
§6.3 group order, §6.4 current deviations.
|
||||
|
||||
## Summary
|
||||
|
||||
Two related changes to search:
|
||||
|
||||
1. **Scope** — a search started inside a library searches *that* library.
|
||||
Started from Home, `/library`, or the search tab, it searches everything.
|
||||
The active scope shows as a chip row under the search bar, preselected from
|
||||
context and freely changeable without retyping.
|
||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||
|
||||
## Motivation
|
||||
|
||||
Searching "office" while browsing TV currently returns music albums, because
|
||||
both search entry points call the same unscoped query. The user has already
|
||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||
and pushes the relevant result below unrelated media.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code — **most of the plumbing is already there.** This is
|
||||
substantially a wiring task, not new infrastructure.
|
||||
|
||||
1. **`SearchOptions` already carries the filter.**
|
||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||
|
||||
2. **Rust already honours `include_item_types` on both paths** — online
|
||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||
options mapping) and offline
|
||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||
type filter from it). **Do not add Rust code for filtering.**
|
||||
|
||||
3. **Per-page list search already does this correctly.**
|
||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||
the reference for the call shape, including the `requestId` handling.
|
||||
|
||||
4. **The gap is exactly one function.**
|
||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||
any scope. Both callers
|
||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||
it.
|
||||
|
||||
5. **Group order is hardcoded in markup.**
|
||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||
renders three fixed sections in source order.
|
||||
|
||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||
command for this.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope model
|
||||
|
||||
One `SearchScope` type, defined once and shared:
|
||||
|
||||
| Scope | `includeItemTypes` | Chip label |
|
||||
|-------|--------------------|------------|
|
||||
| `all` | *unset* | All |
|
||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||
| `movies` | `Movie` | Movies |
|
||||
| `tv` | `Series`, `Episode` | TV |
|
||||
|
||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||
|
||||
### Route → scope resolution (DR-063)
|
||||
|
||||
A pure function, unit-testable without a DOM:
|
||||
|
||||
```ts
|
||||
resolveSearchScope(pathname: string): SearchScope
|
||||
```
|
||||
|
||||
- `/library/music*` → `music`
|
||||
- `/library/movies*` → `movies`
|
||||
- `/library/tv*` → `tv`
|
||||
- `/`, `/library`, `/search`, anything else → `all`
|
||||
|
||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||
current route list before finalising — do not assume this table is exhaustive.
|
||||
|
||||
### Scope is a starting point, not a lock (DR-064)
|
||||
|
||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||
their choice governs until they leave the search surface. Concretely: derive the
|
||||
initial value from the route, hold it in component state, and do not re-derive
|
||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||
|
||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||
keeps the current scope.
|
||||
|
||||
### Threading scope through the store (DR-065)
|
||||
|
||||
Extend the store's search signature to accept an optional scope and pass
|
||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||
additive parameter — no caller should break.
|
||||
|
||||
### Group order (DR-066, DR-067)
|
||||
|
||||
Persist an ordered array of group ids:
|
||||
|
||||
```
|
||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||
```
|
||||
|
||||
Rendering composes scope and order as **two independent axes**, in this order:
|
||||
|
||||
1. drop groups outside the active scope,
|
||||
2. sort the remainder by the user's saved order,
|
||||
3. omit groups that came back empty.
|
||||
|
||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||
the worked example.
|
||||
|
||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||
unusable with a screen reader and on any pointerless input.
|
||||
|
||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||
the new ones.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Ranking *within* a group. Order is presentation-only.
|
||||
- Server-side search ranking or the Jellyfin query itself.
|
||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||
is already implicitly scoped by its own `itemType`.
|
||||
- Any Rust change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||
- [ ] The chip row renders under the search bar on both the search page and the
|
||||
in-library header search, with the context-derived chip preselected.
|
||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||
query preserves the selected chip.
|
||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||
empty groups omitted and relative order preserved.
|
||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||
restarts, and ships with the documented default.
|
||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||
don't reimplement).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||
|
||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||
- The compose step: scope filter + user order + empty-group omission, including
|
||||
the "narrow then widen restores order" case and a stored order containing an
|
||||
unknown id.
|
||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||
the existing stale-`requestId` guard still discards superseded responses.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||
document is the implementation plan.
|
||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||
- Another session may be active in this repo. Check `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -1,233 +0,0 @@
|
||||
# Spec: Background audio for video playback (Android)
|
||||
|
||||
**Status:** Draft
|
||||
**Scope:** Android only (v1). Linux noted as future work.
|
||||
**Branch base:** `android-picture-in-picture`
|
||||
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
|
||||
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a per-player toggle that lets the **audio** of a video keep playing when the
|
||||
app is backgrounded or the screen is locked, while **video decoding stops**.
|
||||
When the app returns to the foreground, video decoding resumes from the current
|
||||
audio position.
|
||||
|
||||
This is the audio-first counterpart to the existing Picture-in-Picture feature
|
||||
(which keeps the *whole video* decoding in a floating window). The two are
|
||||
mutually exclusive: enabling background audio suppresses auto-PiP.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
|
||||
concert films) want to lock the phone or switch apps and keep listening without
|
||||
draining battery on video decode or needing a visible floating window.
|
||||
|
||||
## Background: how playback actually works here
|
||||
|
||||
Two facts drive the entire design (verified in code, not assumed):
|
||||
|
||||
1. **Video renders through the HTML5 `<video>` element in the WebView on both
|
||||
platforms.** The native ExoPlayer *video* surface path is disabled — see the
|
||||
INTERIM override in
|
||||
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
|
||||
around the `playerPlayItem` response handling (`useHtml5Element` is forced
|
||||
`true`, native backend is stopped). So "video decoding" == the WebView
|
||||
`<video>` element, and the WebView is what Android suspends on background.
|
||||
|
||||
2. **An Android WebView `<video>` element does not keep playing audio when the
|
||||
app is backgrounded / locked.** The system throttles the WebView and media
|
||||
pauses. Keeping audio alive in the background requires a **native foreground
|
||||
media service**, which already exists for music:
|
||||
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
|
||||
+
|
||||
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
|
||||
(ExoPlayer) + `MediaSessionCompat`.
|
||||
|
||||
**Therefore the design is a handoff**, not "keep the WebView alive": on
|
||||
background, stop the WebView `<video>` and start audio-only playback of the same
|
||||
item through the existing native ExoPlayer audio service; on foreground, hand
|
||||
back to the WebView `<video>`.
|
||||
|
||||
This also aligns with the project's one-directional playback rule
|
||||
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
|
||||
player (WebView element **or** native audio service) drives position; the UI and
|
||||
MediaSession consume it. The handoff is a change of *which* player is
|
||||
authoritative, and must transfer position cleanly.
|
||||
|
||||
## User-facing behavior
|
||||
|
||||
### The toggle
|
||||
|
||||
- A toggle button in the video player controls (next to the existing PiP /
|
||||
fullscreen buttons in
|
||||
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
|
||||
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
|
||||
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
|
||||
Android with a native audio service available. Hidden on Linux in v1.
|
||||
- State is a UI preference on the player. Consider persisting the last choice
|
||||
per user (see Open Questions) — v1 may default OFF each session.
|
||||
|
||||
### When toggle is ON and the app goes to background / screen locks
|
||||
|
||||
1. Auto-PiP is suppressed (see "Interaction with PiP").
|
||||
2. The WebView `<video>` is paused and its decode stopped (release the media
|
||||
source so the decoder is freed, not merely `pause()`).
|
||||
3. Native audio-only playback of the same item starts at the current position,
|
||||
through `JellyTauPlaybackService` (foreground notification + lockscreen
|
||||
controls via the existing `MediaSessionCompat`).
|
||||
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
|
||||
native player (existing music behavior — reused, not rebuilt).
|
||||
|
||||
### When toggle is ON and the app returns to foreground
|
||||
|
||||
1. Native audio playback stops; its final position is captured.
|
||||
2. WebView `<video>` reloads/resumes at that position and continues as normal
|
||||
audiovisual playback.
|
||||
3. Playback state (playing/paused) is preserved across the handoff.
|
||||
|
||||
### When toggle is OFF (default)
|
||||
|
||||
Current behavior is unchanged: backgrounding video auto-enters PiP
|
||||
(`onUserLeaveHint` → `PictureInPictureManager.enterPip`).
|
||||
|
||||
## Interaction with PiP
|
||||
|
||||
The toggle chooses one behavior or the other:
|
||||
|
||||
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
|
||||
bridge already exists,
|
||||
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
|
||||
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
|
||||
- Toggle **OFF** → `setAutoEnterEnabled(true)`. Background → PiP (status quo).
|
||||
|
||||
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
|
||||
it enabled, and re-assert the correct value whenever the toggle changes, so a
|
||||
stale setting can't leak into the next player.
|
||||
|
||||
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
|
||||
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
|
||||
> false during normal playback. Confirm during implementation how auto-PiP is
|
||||
> actually triggering today (it may rely on a different signal), because the
|
||||
> background-audio handoff needs the same "is a local video active" signal to
|
||||
> know it should fire. **This is a load-bearing unknown — resolve it first
|
||||
> (Phase 0).**
|
||||
|
||||
## Technical design
|
||||
|
||||
### The audio-only stream
|
||||
|
||||
Jellyfin can transcode/stream a video item as audio-only. Add a repository
|
||||
method (mirroring
|
||||
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
|
||||
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
|
||||
**audio-only stream URL for a video item** at a given audio-stream index — so
|
||||
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
|
||||
carries over. Prefer direct-play of the audio stream where the container/codec
|
||||
allows; transcode to a broadly-supported audio codec otherwise.
|
||||
|
||||
Position semantics must match between the WebView `<video>` timeline and the
|
||||
audio stream (account for the transcoded-HLS `seekOffset` model already in the
|
||||
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
|
||||
|
||||
### Backend command surface (Rust)
|
||||
|
||||
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
|
||||
camelCase param rule and `Result<T, String>` convention):
|
||||
|
||||
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
|
||||
— stop WebView authority, start native audio-only playback at position; makes
|
||||
the native player authoritative. Emits state via the existing player-event
|
||||
channel so MediaSession/UI stay consumers.
|
||||
- `player_exit_background_audio() -> position_seconds` — stop native audio,
|
||||
return final position for the WebView to resume from; restores WebView
|
||||
authority.
|
||||
|
||||
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
|
||||
than adding a parallel path.
|
||||
|
||||
### Android native
|
||||
|
||||
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
|
||||
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
|
||||
all already implemented for music).
|
||||
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
|
||||
one so the frontend can signal "prepare for background audio handoff" tied to
|
||||
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
|
||||
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
|
||||
enter PiP; instead trigger the handoff command.
|
||||
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
|
||||
player event callbacks; bind locked `AutoplayDecision` to a `let` before
|
||||
matching).
|
||||
|
||||
### Frontend (VideoPlayer.svelte)
|
||||
|
||||
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
|
||||
- Listen for Android lifecycle background/foreground signals (via a bridge event
|
||||
or existing visibility hooks) and:
|
||||
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
|
||||
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
|
||||
audio).
|
||||
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
|
||||
returned position, restore play/pause state.
|
||||
- **Follow the native-mode pitfall** (memory:
|
||||
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
|
||||
`onMount`. Keep the handoff logic out of that window.
|
||||
- Dual-audio is the key regression risk: at every handoff exactly one of
|
||||
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
|
||||
*before* starting the next, mirroring the existing HLS cleanup discipline.
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 0 — De-risk (do first):**
|
||||
- Confirm what actually triggers today's auto-PiP given video is on the
|
||||
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
|
||||
- Spike: obtain an audio-only stream URL for a video item and play it through
|
||||
the native audio service; measure position accuracy and that WebView audio
|
||||
is fully silenced (no dual audio).
|
||||
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
|
||||
commands + events.
|
||||
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
|
||||
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
|
||||
teardown discipline.
|
||||
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
|
||||
carry-over, edge cases (calls, headphone unplug, autoplay-next during
|
||||
background audio).
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust: unit tests for the audio-only URL builder and the two commands
|
||||
(`cargo test`, `bun run test:rust`).
|
||||
- IPC param-naming integration tests for any new commands
|
||||
(`bun run test -- tauriIntegration.test.ts`).
|
||||
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
|
||||
the handoff state machine (mirror the existing
|
||||
`VideoPlayer.logic.test.ts`).
|
||||
- Manual on-device matrix:
|
||||
- toggle ON: home button → audio continues, video stops decoding; return →
|
||||
video resumes at position; playing/paused preserved.
|
||||
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
|
||||
resumes.
|
||||
- toggle OFF: background → PiP (unchanged).
|
||||
- No dual audio at any transition. No audio leak after leaving the player.
|
||||
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
|
||||
- Autoplay-next fires correctly if an episode ends during background audio.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Persist the toggle per user/series, or default OFF each session?**
|
||||
(Recommend: remember last choice; series-level like the audio-track
|
||||
preference is a nice-to-have.)
|
||||
2. **Autoplay-next during background audio** — should the next episode start as
|
||||
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
|
||||
continue as audio-only.)
|
||||
3. **Subtitles** are irrelevant in audio-only mode but must restore on
|
||||
foreground — confirm they survive the `<video>` teardown/reload.
|
||||
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
|
||||
— `onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Linux background audio (desktop windows keep running unfocused; low value).
|
||||
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
|
||||
- Re-enabling the native ExoPlayer *video* surface path.
|
||||
@@ -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
|
||||
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||
|
||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||
`traceability-check.yml` rather than reviving the old file.
|
||||
|
||||
## 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,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.10.1",
|
||||
"notes": "\nA single fix, for something that had been quietly overriding a choice you made.\n\n### 🐛 Fixes\n\n- **Locking the screen no longer keeps playing a video's audio unless you asked\n it to.** The player has a background-audio button: turn it on and the sound\n carries on when you lock the screen or leave the app, turn it off and playback\n stops. It stopped working when video moved to the native renderer — which plays\n through a media service designed to keep going while the app is hidden — and\n nothing was left to stop it. So the audio continued whether the button was on\n or off, and there was no way to make it behave otherwise. The button governs it\n again: with it off, locking the screen pauses the video and unlocking resumes\n where you were; with it on, the audio continues as before. Music is untouched —\n it keeps playing when backgrounded, as a music player should — and a video in a\n picture-in-picture window keeps playing too, because the window is still on\n screen. If you had already paused before locking, it stays paused.\n (UR-040 → DR-224)",
|
||||
"pub_date": "2026-08-22T10:29:00Z",
|
||||
"platforms": {
|
||||
"linux-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaW10NkxGa3IwazZlVXRucStkcVJUTmJIM0dIUmc2WEtjNkprQkNmcVhncVNNak9FSnIwT2NaZG1DYUVJd0d6Umt6SlluQ2FIUUtFaDZYTVNJZjZKWkFZPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MzkwMDY3CWZpbGU6SmVsbHlUYXVfMC4xMC4xX2FtZDY0LkFwcEltYWdlCjU0azdCczA4N2p4Z3hQVzhYK3RIQmk0b1RBbk52dCtvYVduNEtiOHM4YzZHNklqTnRhTFg2ZkVQSm1PNUVuMmt1M3hCYnZDUHMxc0pIZ3FpZktqNkFRPT0K",
|
||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.10.1/JellyTau_0.10.1_amd64.AppImage"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaXVPdTF3ZUR6TFBvdnluM2xCZ2t6VVF6UlpwY1J2clRabHNyTUdTT1krMHBpZ3R6Zk4rT0c2dkJSa3F3YlREV0YzM01IdFU0WkdoYW4xRnplT21IUmd3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg3MzkxMDI1CWZpbGU6SmVsbHlUYXVfMC4xMC4xX3g2NC1zZXR1cC5leGUKdzVEa2d2d1dxU3lIZENaTnNTNGRBZS9IY0lFdnp2aEhES09TdGFUY0o2Vlk2ZytyWlBvQVZjMUI1V1pQU0o4TENubWJkZEkzbUVTZkFFc0hlVmVBRFE9PQo=",
|
||||
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.10.1/JellyTau_0.10.1_x64-setup.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.18",
|
||||
"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",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.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",
|
||||
"release:notes": "bun run scripts/release-notes.ts"
|
||||
},
|
||||
"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,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build and deploy Android APK in one command
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Build and Deploy Android APK"
|
||||
echo ""
|
||||
|
||||
# Pass all args (build type and/or --clean) through to the build script.
|
||||
./scripts/build-android.sh "$@"
|
||||
|
||||
echo ""
|
||||
|
||||
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||
BUILD_TYPE="debug"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||
@@ -1,59 +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 ""
|
||||
|
||||
# Parse args: build type (debug/release) and optional --clean flag.
|
||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||
BUILD_TYPE="debug"
|
||||
CLEAN="${CLEAN:-0}"
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--clean) CLEAN=1 ;;
|
||||
debug|release) BUILD_TYPE="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||
if [ "$CLEAN" = "1" ]; then
|
||||
echo "🧹 Clearing build caches (clean build)..."
|
||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||
npm install > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Configure release signing from .env (single source of truth). Must run
|
||||
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||
./scripts/write-keystore-properties.sh
|
||||
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,85 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
||||
# that motivated this check.
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
)
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
[[ "$file" == "$allowed" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
file="${line%%:*}"
|
||||
case "$file" in
|
||||
*.test.*) continue ;;
|
||||
esac
|
||||
if is_allowed "$file"; then
|
||||
echo " ⏭️ allowlisted: $line"
|
||||
continue
|
||||
fi
|
||||
violations+="$line"$'\n'
|
||||
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
||||
echo " the real gate for subtler leaks.)"
|
||||
@@ -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,137 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* release-notes.ts — turn a commit range into capability-level release notes
|
||||
* using the TRACES graph instead of raw commit subjects.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/release-notes.ts [<range>]
|
||||
* bun run scripts/release-notes.ts v0.0.15..HEAD
|
||||
*
|
||||
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
|
||||
*
|
||||
* How it works:
|
||||
* 1. `git diff --name-only <range>` → files the range changed.
|
||||
* 2. Read each changed file's `TRACES:` comments → requirement IDs.
|
||||
* 3. Resolve IDs to descriptions from docs/requirements.md.
|
||||
* 4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits
|
||||
* touching one requirement collapse to one line.
|
||||
*
|
||||
* This is a drafting aid for docs/release-checklist.md — review the output,
|
||||
* it does not invent descriptions for untraced changes (those are listed
|
||||
* separately so nothing is silently dropped).
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
|
||||
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
|
||||
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
|
||||
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
|
||||
|
||||
function sh(cmd: string): string {
|
||||
return execSync(cmd, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function defaultRange(): string {
|
||||
try {
|
||||
const tag = sh("git describe --tags --abbrev=0");
|
||||
return `${tag}..HEAD`;
|
||||
} catch {
|
||||
return ""; // no tags: fall through to whole-history diff
|
||||
}
|
||||
}
|
||||
|
||||
/** Map requirement ID → human description, parsed from docs/requirements.md. */
|
||||
function loadRequirementDescriptions(): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
const text = readFileSync("docs/requirements.md", "utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
const m = line.match(REQ_ROW_RE);
|
||||
// First definition wins: the descriptive tables come before the later
|
||||
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
|
||||
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function changedFiles(range: string): string[] {
|
||||
const cmd = range
|
||||
? `git diff --name-only ${range}`
|
||||
: "git ls-files"; // untagged repo: describe everything currently traced
|
||||
return sh(cmd)
|
||||
.split("\n")
|
||||
.filter((f) => f && existsSync(f));
|
||||
}
|
||||
|
||||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||||
function idsFromFiles(files: string[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const trace of content.matchAll(TRACE_RE)) {
|
||||
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const range = process.argv[2] ?? defaultRange();
|
||||
const descriptions = loadRequirementDescriptions();
|
||||
const files = changedFiles(range);
|
||||
const ids = idsFromFiles(files);
|
||||
|
||||
const features: string[] = []; // UR
|
||||
const improvements: string[] = []; // DR / IR
|
||||
const unknown: string[] = []; // traced but not in requirements.md
|
||||
|
||||
for (const id of [...ids].sort()) {
|
||||
const desc = descriptions.get(id);
|
||||
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
|
||||
if (!desc) {
|
||||
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
|
||||
continue;
|
||||
}
|
||||
const line = `- ${desc} (${id})`;
|
||||
if (id.startsWith("UR")) features.push(line);
|
||||
else improvements.push(line);
|
||||
}
|
||||
|
||||
const header = range || "(entire history — no tags found)";
|
||||
const out: string[] = [`## Release notes — ${header}`, ""];
|
||||
|
||||
if (features.length) out.push("### ✨ Features", ...features, "");
|
||||
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
|
||||
if (unknown.length)
|
||||
out.push(
|
||||
"### ⚠️ Traced IDs missing from requirements.md",
|
||||
...unknown.map((id) => `- ${id}`),
|
||||
"",
|
||||
);
|
||||
|
||||
const untraced = files.filter((f) => {
|
||||
try {
|
||||
return !/TRACES:/.test(readFileSync(f, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (untraced.length)
|
||||
out.push(
|
||||
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
|
||||
...untraced.map((f) => `- ${f}`),
|
||||
"",
|
||||
);
|
||||
|
||||
if (!features.length && !improvements.length)
|
||||
out.push("_No traced requirements in this range._", "");
|
||||
|
||||
console.log(out.join("\n"));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,111 +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
|
||||
|
||||
# AndroidManifest.xml. `tauri android init` regenerates gen/android from
|
||||
# tauri.conf.json and would drop our hand-maintained entries (media playback
|
||||
# service + permissions, hardware acceleration, picture-in-picture attributes
|
||||
# on MainActivity), so this tracked copy is the source of truth and must be
|
||||
# restored after a regen. Gradle reads ONLY the gen/ copy - there is no
|
||||
# manifest-merger hook here, so this must be the complete manifest.
|
||||
MANIFEST_SRC="$PROJECT_ROOT/src-tauri/android/src/main/AndroidManifest.xml"
|
||||
MANIFEST_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/AndroidManifest.xml"
|
||||
if [ -f "$MANIFEST_SRC" ]; then
|
||||
cp "$MANIFEST_SRC" "$MANIFEST_DST"
|
||||
echo " Copied: app/src/main/AndroidManifest.xml"
|
||||
fi
|
||||
|
||||
# Custom ProGuard/R8 keep rules. Required for minified release builds:
|
||||
# the player/ and security/ Kotlin classes are loaded by name via JNI from
|
||||
# Rust, so R8 can't see the references and would strip them without this.
|
||||
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
|
||||
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
|
||||
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
|
||||
if [ -f "$PROGUARD_SRC" ]; then
|
||||
cp "$PROGUARD_SRC" "$PROGUARD_DST"
|
||||
echo " Copied: app/proguard-jellytau.pro"
|
||||
fi
|
||||
|
||||
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
|
||||
# low-quality launcher icons from tauri.conf.json (which has no high-res
|
||||
# Android source), so overwrite them with the real committed mipmaps.
|
||||
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
|
||||
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
|
||||
if [ -d "$RES_SRC" ]; then
|
||||
for dir in "$RES_SRC"/mipmap-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
mkdir -p "$RES_DST/$name"
|
||||
cp "$dir"/* "$RES_DST/$name/"
|
||||
echo " Copied res: $name"
|
||||
done
|
||||
|
||||
# values/ (themes.xml): status-bar styling that `tauri android init` does
|
||||
# not generate. Previously this directory was tracked but never copied, so
|
||||
# the theme customizations below never reached a build.
|
||||
if [ -d "$RES_SRC/values" ]; then
|
||||
mkdir -p "$RES_DST/values"
|
||||
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
|
||||
echo " Copied res: values"
|
||||
fi
|
||||
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||
# ic_launcher_monochrome.png would just be dead weight.
|
||||
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
|
||||
|
||||
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
|
||||
# as API-qualified VECTOR drawables:
|
||||
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
|
||||
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
|
||||
# Because drawable-v24 is a more specific match than our unqualified
|
||||
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
|
||||
# the green square robot instead of our jellyfish. Remove them so the
|
||||
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
|
||||
# to the real committed PNGs.
|
||||
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
|
||||
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
|
||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||
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,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
|
||||
#
|
||||
# .env is the single source of truth for local release signing. `tauri android
|
||||
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
|
||||
# from .env before every release build (this is the local mirror of what the CI
|
||||
# workflow does from Gitea secrets).
|
||||
#
|
||||
# Required .env vars:
|
||||
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
|
||||
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "❌ $ENV_FILE not found — cannot configure release signing." >&2
|
||||
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
|
||||
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load .env without leaking it into the caller's environment beyond what we need.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
|
||||
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
|
||||
|
||||
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$PROPS")"
|
||||
umask 077
|
||||
cat > "$PROPS" <<EOF
|
||||
storeFile=$ANDROID_KEYSTORE_FILE
|
||||
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||
keyAlias=$ANDROID_KEY_ALIAS
|
||||
keyPassword=$ANDROID_KEY_PASSWORD
|
||||
EOF
|
||||
|
||||
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
|
||||
@@ -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,24 +0,0 @@
|
||||
# JellyTau custom keep rules.
|
||||
#
|
||||
# These classes are loaded by name from the Rust backend via JNI
|
||||
# (env.find_class / class-loader lookups), so R8 cannot see the
|
||||
# references and would otherwise strip or rename them in a minified
|
||||
# release build — causing an instant ClassNotFoundException crash on
|
||||
# startup. See src-tauri/src/player/android/mod.rs and
|
||||
# src-tauri/src/credentials.rs.
|
||||
-keep class com.dtourolle.jellytau.player.** { *; }
|
||||
-keep class com.dtourolle.jellytau.security.** { *; }
|
||||
|
||||
# Picture-in-picture is driven from the WebView through an
|
||||
# @JavascriptInterface bridge, so the only references to these methods live
|
||||
# in JavaScript. R8 sees them as unused and would strip them, silently
|
||||
# breaking the PiP button in release builds only.
|
||||
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
|
||||
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
|
||||
-keep class androidx.media3.** { *; }
|
||||
-dontwarn androidx.media3.**
|
||||
@@ -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,69 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Authoritative AndroidManifest for JellyTau.
|
||||
|
||||
NOTE: this is NOT a manifest-merger fragment. Gradle only ever reads
|
||||
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
|
||||
regenerates that file from tauri.conf.json - dropping everything below.
|
||||
scripts/sync-android-sources.sh copies this file over the generated one,
|
||||
so this is the full manifest and the single source of truth.
|
||||
|
||||
(An earlier version of this file was a partial <application> fragment on the
|
||||
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
|
||||
declared never reached any built APK. It is folded in properly below.)
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.jellytau"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Media playback service for lockscreen controls -->
|
||||
<service
|
||||
android:name="com.dtourolle.jellytau.player.JellyTauPlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,403 +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 }
|
||||
|
||||
/**
|
||||
* Coarse override for whether backgrounding the app should auto-enter PiP.
|
||||
*
|
||||
* This is NOT what excludes audio/browsing/cast from PiP — that is the
|
||||
* PictureInPictureManager.canEnterPip guard, which requires a local video
|
||||
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
|
||||
* flag is only toggled by the background-audio feature (via
|
||||
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
|
||||
* auto-PiP stay mutually exclusive.
|
||||
*/
|
||||
@Volatile
|
||||
private var autoEnterPipEnabled = true
|
||||
|
||||
/**
|
||||
* Whether the user armed background-audio mode on the current video (UR-040).
|
||||
* When true, leaving the app hands audio off to the native ExoPlayer audio
|
||||
* service (frontend-driven) instead of entering PiP, and video decode stops.
|
||||
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
|
||||
*/
|
||||
@Volatile
|
||||
private var backgroundAudioEnabled = false
|
||||
|
||||
/**
|
||||
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
|
||||
* can dispatch DOM events into it (native → frontend signalling).
|
||||
*/
|
||||
private var mediaWebView: WebView? = null
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user leaves the app via Home or the gesture equivalent
|
||||
* (but NOT via Back). This is the standard hook for auto-entering PiP so
|
||||
* video keeps playing in a floating window instead of being backgrounded.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*/
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// Never enter PiP while background-audio mode is armed — the two are mutually
|
||||
// exclusive (the handoff runs from onStop instead).
|
||||
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
|
||||
PictureInPictureManager.canEnterPip(this)) {
|
||||
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
|
||||
PictureInPictureManager.enterPip(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app is no longer visible (Home, app switch, or screen lock). When
|
||||
* background-audio mode is armed, tell the frontend to hand video playback off
|
||||
* to the native audio service. onStop (rather than onUserLeaveHint) is used
|
||||
* because it fires on screen-lock too, which is the primary use case (UR-040).
|
||||
*
|
||||
* TRACES: UR-040 | IR-025
|
||||
*/
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-background")
|
||||
}
|
||||
}
|
||||
|
||||
/** The app is visible again — tell the frontend to resume WebView video. */
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (backgroundAudioEnabled) {
|
||||
dispatchWebEvent("jellytau-foreground")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a DOM CustomEvent into the WebView (native → frontend). Mirrors the
|
||||
* evaluateJavascript pattern already used to unmute video elements. Posted to
|
||||
* the WebView thread; safe no-op if the WebView isn't found yet.
|
||||
*/
|
||||
private fun dispatchWebEvent(name: String) {
|
||||
val webView = mediaWebView ?: run {
|
||||
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
|
||||
return
|
||||
}
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"window.dispatchEvent(new CustomEvent('$name'));",
|
||||
null
|
||||
)
|
||||
android.util.Log.d("MainActivity", "Dispatched web event: $name")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
android.util.Log.d("MainActivity", "PiP mode changed: $isInPictureInPictureMode")
|
||||
PictureInPictureManager.onPipModeChanged(this, isInPictureInPictureMode)
|
||||
}
|
||||
|
||||
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...")
|
||||
mediaWebView = webView
|
||||
|
||||
// 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")
|
||||
|
||||
// Add JavaScript interface for picture-in-picture control.
|
||||
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
|
||||
// methods are invoked on a WebView binder thread.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
@JavascriptInterface
|
||||
fun enterPip() {
|
||||
handler.post { PictureInPictureManager.enterPip(this@MainActivity) }
|
||||
}
|
||||
|
||||
/** Whether the PiP button should be offered in the player UI at all. */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean {
|
||||
return PictureInPictureManager.isPipSupported(this@MainActivity)
|
||||
}
|
||||
|
||||
/** Whether entering PiP would work right now (local video playing). */
|
||||
@JavascriptInterface
|
||||
fun canEnterPip(): Boolean {
|
||||
return PictureInPictureManager.canEnterPip(this@MainActivity)
|
||||
}
|
||||
|
||||
/** Let the frontend opt out of auto-PiP (e.g. while casting). */
|
||||
@JavascriptInterface
|
||||
fun setAutoEnterEnabled(enabled: Boolean) {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
// Add JavaScript interface for background-audio mode (UR-040). The frontend
|
||||
// arms/disarms it via the player toggle; the Activity uses the flag in its
|
||||
// lifecycle overrides to decide between the audio handoff and PiP.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Frontend arms/disarms background-audio mode for the current video. */
|
||||
@JavascriptInterface
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
backgroundAudioEnabled = enabled
|
||||
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
|
||||
}
|
||||
|
||||
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
// Network transport reporting for the WiFi-only download gate (UR-053).
|
||||
// The frontend polls these on demand and re-pumps the download queue when
|
||||
// the 'jellytau-network-changed' event fires.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
|
||||
@JavascriptInterface
|
||||
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
|
||||
|
||||
/** Whether the active network is unmetered. */
|
||||
@JavascriptInterface
|
||||
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
|
||||
|
||||
/** Whether downloads may run given the wifi-only preference. */
|
||||
@JavascriptInterface
|
||||
fun isAcceptable(wifiOnly: Boolean): Boolean =
|
||||
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
|
||||
|
||||
/** Whether native network detection is available at all (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
// 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
|
||||
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,152 +0,0 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
/**
|
||||
* Reports the *kind* of network the device is on, so downloads can be gated on
|
||||
* "unmetered only" (the WiFi-only setting).
|
||||
*
|
||||
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
|
||||
* answers a different question: whether the Jellyfin *server* is reachable,
|
||||
* derived from real request outcomes. Reachability and transport type are
|
||||
* orthogonal — you can be on WiFi with a dead server, or on cellular with a
|
||||
* perfectly reachable one.
|
||||
*
|
||||
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
|
||||
* and we report UNKNOWN (which the gate treats as "not acceptable" when
|
||||
* wifi-only is on, failing closed rather than burning mobile data).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
object NetworkTypeMonitor {
|
||||
private const val TAG = "NetworkTypeMonitor"
|
||||
|
||||
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
|
||||
const val TYPE_NONE = "none"
|
||||
const val TYPE_WIFI = "wifi"
|
||||
const val TYPE_ETHERNET = "ethernet"
|
||||
const val TYPE_CELLULAR = "cellular"
|
||||
const val TYPE_OTHER = "other"
|
||||
const val TYPE_UNKNOWN = "unknown"
|
||||
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** Invoked on any network change; set by [startWatching]. */
|
||||
@Volatile
|
||||
private var onChange: (() -> Unit)? = null
|
||||
|
||||
private fun connectivityManager(context: Context): ConnectivityManager? =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
/**
|
||||
* Current transport type of the active network.
|
||||
*
|
||||
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
|
||||
* distinguish "definitely offline" from "couldn't tell".
|
||||
*/
|
||||
fun currentType(context: Context): String {
|
||||
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
|
||||
val network = cm.activeNetwork ?: return TYPE_NONE
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
|
||||
|
||||
return when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
|
||||
else -> TYPE_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active network is unmetered.
|
||||
*
|
||||
* This is the bit that actually matters for the WiFi-only gate: a phone
|
||||
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
|
||||
* cellular data the setting exists to protect. Checking NOT_METERED rather
|
||||
* than the transport alone means tethering doesn't quietly burn a data plan.
|
||||
*/
|
||||
fun isUnmetered(context: Context): Boolean {
|
||||
val cm = connectivityManager(context) ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads may run right now given the wifi-only preference.
|
||||
*
|
||||
* Ethernet counts as acceptable (it is unmetered in practice and is what
|
||||
* Android TV devices use). Cellular never does. When wifi-only is off this is
|
||||
* always true — the gate simply isn't engaged.
|
||||
*/
|
||||
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
|
||||
if (!wifiOnly) return true
|
||||
val type = currentType(context)
|
||||
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
|
||||
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
|
||||
return isUnmetered(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the network changes, so a blocked
|
||||
* download queue can be re-pumped the moment an acceptable network appears.
|
||||
* Without this the queue would stall until some unrelated event pumped it.
|
||||
*
|
||||
* Idempotent: a second call replaces the previous callback.
|
||||
*/
|
||||
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
|
||||
val cm = connectivityManager(context) ?: run {
|
||||
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
|
||||
return
|
||||
}
|
||||
|
||||
stopWatching(context)
|
||||
onChange = onNetworkChanged
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
android.util.Log.d(TAG, "Network available")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
android.util.Log.d(TAG, "Network lost")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
// Fires when e.g. metered-ness flips without the network itself changing.
|
||||
onChange?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cm.registerNetworkCallback(request, cb)
|
||||
callback = cb
|
||||
android.util.Log.d(TAG, "Network callback registered")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister the network callback, if one is active. */
|
||||
fun stopWatching(context: Context) {
|
||||
val cb = callback ?: return
|
||||
val cm = connectivityManager(context)
|
||||
try {
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
callback = null
|
||||
onChange = null
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.util.Rational
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||
|
||||
/**
|
||||
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
|
||||
*
|
||||
* TRACES: UR-041 | IR-026 | DR-053
|
||||
*
|
||||
* PiP shrinks the whole Activity into a floating window, so the only thing that
|
||||
* should remain visible is the video SurfaceView that [VideoOverlayManager]
|
||||
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
|
||||
* hidden for the duration - it is opaque and sits *above* the surface, so
|
||||
* leaving it visible would occlude the video entirely.
|
||||
*
|
||||
* Playback itself is untouched: ExoPlayer keeps rendering into the same surface
|
||||
* across the transition, so entering and leaving PiP never interrupts the video.
|
||||
*/
|
||||
object PictureInPictureManager {
|
||||
|
||||
private const val TAG = "PictureInPictureManager"
|
||||
|
||||
/** Action for the play/pause RemoteAction shown inside the PiP window. */
|
||||
private const val ACTION_MEDIA_CONTROL = "com.dtourolle.jellytau.PIP_MEDIA_CONTROL"
|
||||
private const val EXTRA_CONTROL_TYPE = "control_type"
|
||||
private const val CONTROL_PLAY = 1
|
||||
private const val CONTROL_PAUSE = 2
|
||||
|
||||
/** Request codes must differ per action or the PendingIntents collapse into one. */
|
||||
private const val REQUEST_PLAY = 101
|
||||
private const val REQUEST_PAUSE = 102
|
||||
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
|
||||
* and the user (or device manufacturer) can disable the feature per-app.
|
||||
*/
|
||||
fun isPipSupported(activity: Activity): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
return activity.packageManager.hasSystemFeature(
|
||||
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether entering PiP right now makes sense: a native video must actually
|
||||
* be playing locally. Audio-only playback and remote/cast sessions render
|
||||
* nothing on this device, so a PiP window would be an empty black box.
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
return try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
player.getSurfaceView() != null &&
|
||||
VideoOverlayManager.isVideoSurfaceAttached()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "canEnterPip check failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter picture-in-picture, sizing the window to the video's aspect ratio.
|
||||
*
|
||||
* @return true if the system accepted the transition.
|
||||
*/
|
||||
fun enterPip(activity: Activity): Boolean {
|
||||
if (!canEnterPip(activity)) {
|
||||
android.util.Log.d(TAG, "Not entering PiP: no local video playing")
|
||||
return false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
|
||||
return try {
|
||||
val params = buildParams(activity)
|
||||
val entered = activity.enterPictureInPictureMode(params)
|
||||
android.util.Log.d(TAG, "enterPictureInPictureMode returned $entered")
|
||||
entered
|
||||
} catch (e: Exception) {
|
||||
// IllegalStateException here means PiP is disallowed (e.g. the user
|
||||
// turned it off in system settings). Never crash over it.
|
||||
android.util.Log.e(TAG, "Failed to enter PiP", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build PiP params: aspect ratio from the current video, plus a play/pause
|
||||
* RemoteAction reflecting the live playback state.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildParams(activity: Activity): PictureInPictureParams {
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
|
||||
aspectRatioFor()?.let { builder.setAspectRatio(it) }
|
||||
builder.setActions(listOf(buildPlayPauseAction(activity)))
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The video's aspect ratio, clamped to the range Android accepts.
|
||||
*
|
||||
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
|
||||
* IllegalArgumentException, which would otherwise take down the Activity on
|
||||
* unusually tall or wide content.
|
||||
*/
|
||||
private fun aspectRatioFor(): Rational? {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val surface = player.getSurfaceView() ?: return null
|
||||
// The surface has already been letterboxed to the video's aspect ratio
|
||||
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
|
||||
val width = surface.width
|
||||
val height = surface.height
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
val minRatio = 1.0 / 2.39
|
||||
val maxRatio = 2.39
|
||||
val clamped = ratio.coerceIn(minRatio, maxRatio)
|
||||
|
||||
// Scale to integers; Rational(width, height) directly can overflow for
|
||||
// large surfaces, and the clamped value may not match the raw pixels.
|
||||
return Rational((clamped * 1000).toInt(), 1000)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
Quad(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
CONTROL_PAUSE,
|
||||
REQUEST_PAUSE
|
||||
)
|
||||
} else {
|
||||
Quad(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Play",
|
||||
CONTROL_PLAY,
|
||||
REQUEST_PLAY
|
||||
)
|
||||
}
|
||||
|
||||
val intent = Intent(ACTION_MEDIA_CONTROL)
|
||||
.putExtra(EXTRA_CONTROL_TYPE, controlType)
|
||||
// Explicit package keeps the broadcast internal to the app.
|
||||
.setPackage(activity.packageName)
|
||||
|
||||
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
val pendingIntent = android.app.PendingIntent.getBroadcast(
|
||||
activity,
|
||||
requestCode,
|
||||
intent,
|
||||
flags
|
||||
)
|
||||
|
||||
return RemoteAction(
|
||||
Icon.createWithResource(activity, iconRes),
|
||||
title,
|
||||
title,
|
||||
pendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D
|
||||
)
|
||||
|
||||
/**
|
||||
* Refresh the PiP window's action button so it tracks play/pause state
|
||||
* while the window is open. Safe to call when not in PiP (no-op).
|
||||
*/
|
||||
fun updatePipActions(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (!activity.isInPictureInPictureMode) return
|
||||
try {
|
||||
activity.setPictureInPictureParams(buildParams(activity))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to update PiP actions", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from MainActivity.onPictureInPictureModeChanged.
|
||||
*
|
||||
* Entering: hide the WebView so only the video surface shows, and register
|
||||
* the receiver backing the PiP play/pause button.
|
||||
* Leaving: restore the WebView and unregister.
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
hideWebView(activity)
|
||||
registerReceiver(activity)
|
||||
} else {
|
||||
unregisterReceiver(activity)
|
||||
showWebView()
|
||||
// The surface was laid out against the tiny PiP bounds; re-fit it to
|
||||
// the restored full-screen bounds or the video stays postage-stamp sized.
|
||||
try {
|
||||
JellyTauPlayer.getInstance().fitSurfaceToScreen()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to re-fit surface after PiP", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideWebView(activity: Activity) {
|
||||
val webView = findWebView(activity.window.decorView)
|
||||
if (webView == null) {
|
||||
android.util.Log.w(TAG, "No WebView found to hide for PiP")
|
||||
return
|
||||
}
|
||||
// GONE rather than INVISIBLE: the WebView is opaque, and GONE also stops
|
||||
// it from consuming layout space in the shrunken window.
|
||||
webView.visibility = android.view.View.GONE
|
||||
hiddenWebView = webView
|
||||
android.util.Log.d(TAG, "WebView hidden for PiP")
|
||||
}
|
||||
|
||||
private fun showWebView() {
|
||||
hiddenWebView?.let {
|
||||
it.visibility = android.view.View.VISIBLE
|
||||
android.util.Log.d(TAG, "WebView restored after PiP")
|
||||
}
|
||||
hiddenWebView = null
|
||||
}
|
||||
|
||||
private fun registerReceiver(activity: Activity) {
|
||||
if (receiver != null) return
|
||||
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
}
|
||||
when (intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)) {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(ACTION_MEDIA_CONTROL)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
activity.registerReceiver(r, filter)
|
||||
}
|
||||
receiver = r
|
||||
android.util.Log.d(TAG, "PiP media control receiver registered")
|
||||
}
|
||||
|
||||
private fun unregisterReceiver(activity: Activity) {
|
||||
receiver?.let {
|
||||
try {
|
||||
activity.unregisterReceiver(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Already unregistered - harmless.
|
||||
}
|
||||
}
|
||||
receiver = null
|
||||
}
|
||||
|
||||
private fun findWebView(view: android.view.View): WebView? {
|
||||
if (view is WebView) return view
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
findWebView(view.getChildAt(i))?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,114 +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
|
||||
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||
private var listenerContentView: ViewGroup? = null
|
||||
|
||||
/**
|
||||
* Attach the video SurfaceView to the Activity's content view.
|
||||
*
|
||||
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
||||
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
||||
*
|
||||
* @param activity The Activity to attach the surface to
|
||||
*/
|
||||
fun attachVideoSurface(activity: Activity) {
|
||||
try {
|
||||
// Get the SurfaceView from JellyTauPlayer
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
val surfaceView = player.getSurfaceView()
|
||||
|
||||
if (surfaceView == null) {
|
||||
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the root content view
|
||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||
|
||||
// Remove from parent if already attached elsewhere
|
||||
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||
|
||||
// Configure layout params to fill the screen
|
||||
val layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
|
||||
// Add to content view at index 0 (behind WebView)
|
||||
contentView.addView(surfaceView, 0, layoutParams)
|
||||
attachedSurfaceView = surfaceView
|
||||
|
||||
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||
// device rotation) so the video is letterboxed to fit instead of being
|
||||
// stretched/cropped by the MATCH_PARENT surface.
|
||||
removeLayoutListener()
|
||||
val listener = android.view.View.OnLayoutChangeListener {
|
||||
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
|
||||
player.fitSurfaceToScreen()
|
||||
}
|
||||
}
|
||||
contentView.addOnLayoutChangeListener(listener)
|
||||
contentLayoutListener = listener
|
||||
listenerContentView = contentView
|
||||
|
||||
// Fit once now that the surface is attached and the parent is sized.
|
||||
player.fitSurfaceToScreen()
|
||||
|
||||
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the video SurfaceView from the Activity's view hierarchy.
|
||||
*
|
||||
* @param activity The Activity to detach the surface from
|
||||
*/
|
||||
fun detachVideoSurface(activity: Activity) {
|
||||
try {
|
||||
removeLayoutListener()
|
||||
attachedSurfaceView?.let { surfaceView ->
|
||||
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||
contentView.removeView(surfaceView)
|
||||
attachedSurfaceView = null
|
||||
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a video surface is currently attached.
|
||||
*
|
||||
* @return true if a surface is attached, false otherwise
|
||||
*/
|
||||
fun isVideoSurfaceAttached(): Boolean {
|
||||
return attachedSurfaceView != null
|
||||
}
|
||||
|
||||
private fun removeLayoutListener() {
|
||||
contentLayoutListener?.let { listener ->
|
||||
listenerContentView?.removeOnLayoutChangeListener(listener)
|
||||
}
|
||||
contentLayoutListener = null
|
||||
listenerContentView = null
|
||||
}
|
||||
}
|
||||
@@ -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,611 +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 from Media3 controllers
|
||||
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
|
||||
// session rather than the MediaSessionCompat).
|
||||
//
|
||||
// We do NOT execute on ExoPlayer directly here. Every transport command
|
||||
// is routed to Rust via nativeOnMediaCommand, which is the single decision
|
||||
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
|
||||
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
|
||||
// would double-handle local commands and incorrectly drive the local
|
||||
// player while casting.
|
||||
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||
override fun play() {
|
||||
nativeOnMediaCommand("play")
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
nativeOnMediaCommand("pause")
|
||||
}
|
||||
|
||||
override fun seekToNext() {
|
||||
nativeOnMediaCommand("next")
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
val positionSeconds = positionMs / 1000.0
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
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.
|
||||
//
|
||||
// All transport commands are routed through Rust via nativeOnMediaCommand
|
||||
// rather than directly to ExoPlayer. Rust is the single decision point:
|
||||
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
|
||||
// the command to the remote Jellyfin session. This keeps the lockscreen
|
||||
// working identically for both, and avoids the ExoPlayer-only behaviour
|
||||
// that left remote playback uncontrollable from the lockscreen.
|
||||
setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||
nativeOnMediaCommand("play")
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||
nativeOnMediaCommand("pause")
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||
nativeOnMediaCommand("next")
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||
nativeOnMediaCommand("previous")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||
nativeOnMediaCommand("stop")
|
||||
}
|
||||
|
||||
override fun onSeekTo(position: Long) {
|
||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||
// The scrubber is absolute; Rust owns the seek in absolute terms
|
||||
// (in a background-audio handoff it rebuilds the stream at this
|
||||
// StartTimeTicks). Send the absolute position as-is.
|
||||
val positionSeconds = position / 1000.0
|
||||
nativeOnMediaCommand("seek:$positionSeconds")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Last-known metadata/state, retained so lightweight position ticks can
|
||||
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
|
||||
// and notification. Kept in sync by updateMediaMetadata().
|
||||
private var lastTitle: String = ""
|
||||
private var lastArtist: String = ""
|
||||
private var lastIsPlaying: Boolean = false
|
||||
|
||||
// Base offset (ms) added to every position reported to the lockscreen
|
||||
// MediaSession. During a background-audio handoff the audio stream is
|
||||
// requested with StartTimeTicks = the handoff point, so ExoPlayer reports
|
||||
// position RELATIVE to that point (starting at 0). The metadata duration,
|
||||
// however, is the full absolute length — so without this base the scrubber
|
||||
// thumb sits near 0:00 on a full-length bar. Set from the known handoff
|
||||
// position via setPositionOffset(); 0 for normal playback.
|
||||
private var positionOffsetMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Set the base position offset (seconds) applied to lockscreen positions.
|
||||
* Called by the native layer when entering/exiting a background-audio handoff.
|
||||
* Pass 0 to clear (normal playback, where ExoPlayer's position is absolute).
|
||||
*/
|
||||
fun setPositionOffset(offsetSeconds: Double) {
|
||||
positionOffsetMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
|
||||
android.util.Log.d("JellyTauPlaybackService", "Position offset set to ${positionOffsetMs}ms")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the MediaSession metadata and playback state, plus the notification.
|
||||
*
|
||||
* Call this when the track or play/pause state changes. For frequent position
|
||||
* updates during playback, use [updatePlaybackPosition] instead, which is much
|
||||
* cheaper (no metadata rebuild, no notification rebuild).
|
||||
*/
|
||||
fun updateMediaMetadata(
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String?,
|
||||
duration: Long,
|
||||
position: Long,
|
||||
isPlaying: Boolean
|
||||
) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
|
||||
lastTitle = title
|
||||
lastArtist = artist
|
||||
lastIsPlaying = isPlaying
|
||||
|
||||
// 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 (position made absolute via the base offset).
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
|
||||
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||
// arrive on the session poller thread and can race with (or arrive
|
||||
// before) enableRemoteVolume(); this keeps the session routed to the
|
||||
// remote (absolute) volume slider instead of the local media stream.
|
||||
if (isRemoteVolumeEnabled) {
|
||||
volumeProvider?.let { session.setPlaybackToRemote(it) }
|
||||
}
|
||||
|
||||
// Update the notification
|
||||
updateNotification(title, artist, isPlaying)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the playback position (and play/pause state) on the MediaSession.
|
||||
*
|
||||
* This is the cheap path used for the periodic (250ms) position ticks: it
|
||||
* refreshes the lockscreen scrubber without rebuilding metadata or the
|
||||
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||
* from the last play/pause and drifts out of sync with actual playback.
|
||||
*
|
||||
* @param position Position in milliseconds
|
||||
* @param isPlaying Whether playback is currently active
|
||||
*/
|
||||
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||
val session = mediaSessionCompat ?: return
|
||||
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||
lastIsPlaying = isPlaying
|
||||
// Absolute position for the scrubber = relative ExoPlayer position + base offset.
|
||||
session.setPlaybackState(buildPlaybackState(isPlaying, position + positionOffsetMs))
|
||||
// Only rebuild the notification when the play/pause icon actually flips.
|
||||
if (notificationStateChanged) {
|
||||
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PlaybackStateCompat with the standard transport actions.
|
||||
*
|
||||
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||
* Android does not extrapolate the position past a paused track.
|
||||
*
|
||||
* While remote volume control is enabled (casting), the state is forced to
|
||||
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
|
||||
* (absolute) volume slider for a session that is actively playing; if a
|
||||
* periodic metadata/position push reports paused (e.g. before the remote
|
||||
* session has actually started), reporting STATE_PAUSED here makes the
|
||||
* system tear down the remote slider set up by setPlaybackToRemote() and
|
||||
* fall back to the local media-stream volume.
|
||||
*/
|
||||
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||
val playing = isPlaying || isRemoteVolumeEnabled
|
||||
return 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 (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
position,
|
||||
if (playing) 1.0f else 0.0f
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 870 B |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 4.1 KiB |