Compare commits

..
1 Commits
Author SHA1 Message Date
JellyTau CI 81fe55f4ec chore(updater): manifest for v0.11.6 2026-09-08 00:52:23 +00:00
287 changed files with 15 additions and 84035 deletions
-18
View File
@@ -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
-81
View File
@@ -1,81 +0,0 @@
name: '🏗️ Build and Test JellyTau'
on:
push:
branches:
- master
paths-ignore:
- '**/*.md'
pull_request:
branches:
- master
paths-ignore:
- '**/*.md'
workflow_dispatch:
jobs:
build:
name: Build APK and Run Tests
runs-on: ubuntu-latest
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install
- name: Run frontend tests
run: bun test
- name: Run Rust tests
run: |
cd src-tauri
cargo test
cd ..
- name: Build frontend
run: bun run build
- name: Build Android APK
id: build
run: |
mkdir -p artifacts
bun run tauri android build --apk true
# Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-apk
path: ${{ steps.build.outputs.artifact }}
retention-days: 30
if-no-files-found: error
-337
View File
@@ -1,337 +0,0 @@
name: Build & Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build (e.g., v1.0.0)'
required: false
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install dependencies
run: bun install
- name: Run frontend tests
run: bun run test --run
continue-on-error: false
- name: Run Rust tests
run: bun run test:rust
continue-on-error: false
- name: Check TypeScript
run: bun run check
continue-on-error: false
build-linux:
name: Build Linux
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install dependencies
run: bun install
- name: Build for Linux
run: bun run tauri build
env:
TAURI_SKIP_UPDATER: true
- name: Prepare Linux artifacts
run: |
mkdir -p dist/linux
# Copy AppImage
if [ -f "src-tauri/target/release/bundle/appimage/jellytau_"*.AppImage ]; then
cp src-tauri/target/release/bundle/appimage/jellytau_*.AppImage dist/linux/
fi
# Copy .deb if built
if [ -f "src-tauri/target/release/bundle/deb/jellytau_"*.deb ]; then
cp src-tauri/target/release/bundle/deb/jellytau_*.deb dist/linux/
fi
ls -lah dist/linux/
- name: Upload Linux build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-linux
path: dist/linux/
retention-days: 30
build-android:
name: Build Android
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
uses: android-actions/setup-android@v2
with:
api-level: 33
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Add Android targets
run: |
rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add x86_64-linux-android
- name: Install Android NDK
run: |
sdkmanager "ndk;25.1.8937393"
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-android-
- name: Install dependencies
run: bun install
- name: Build for Android
run: bun run tauri android build
env:
ANDROID_NDK_HOME: ${{ android.ndk-home }}
ANDROID_SDK_ROOT: ${{ android.sdk-root }}
ANDROID_HOME: ${{ android.sdk-root }}
- name: Prepare Android artifacts
run: |
mkdir -p dist/android
# Copy APK
if [ -f "src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk" ]; then
cp src-tauri/gen/android/app/build/outputs/apk/release/app-release.apk dist/android/jellytau-release.apk
fi
# Copy AAB (Android App Bundle) if built
if [ -f "src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab" ]; then
cp src-tauri/gen/android/app/build/outputs/bundle/release/app-release.aab dist/android/jellytau-release.aab
fi
ls -lah dist/android/
- name: Upload Android build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-android
path: dist/android/
retention-days: 30
create-release:
name: Create Release
runs-on: ubuntu-latest
needs: [build-linux, build-android]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version from tag
id: tag_name
run: |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
echo "RELEASE_NAME=JellyTau ${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Download Linux artifacts
uses: actions/download-artifact@v3
with:
name: jellytau-linux
path: artifacts/linux/
- name: Download Android artifacts
uses: actions/download-artifact@v3
with:
name: jellytau-android
path: artifacts/android/
- name: Prepare release notes
id: release_notes
run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}"
echo "## 📱 JellyTau $VERSION Release" > release_notes.md
echo "" >> release_notes.md
echo "### 📦 Downloads" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux" >> release_notes.md
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
echo "- **DEB** - Install via `sudo dpkg -i jellytau_*.deb` (Ubuntu/Debian)" >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- **APK** - Install via `adb install jellytau-release.apk` or sideload via file manager" >> release_notes.md
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
echo "" >> release_notes.md
echo "### ✨ What's New" >> release_notes.md
echo "" >> release_notes.md
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
echo "" >> release_notes.md
echo "### 🔧 Installation" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux (AppImage)" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "chmod +x jellytau_*.AppImage" >> release_notes.md
echo "./jellytau_*.AppImage" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
echo "#### Linux (DEB)" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "sudo dpkg -i jellytau_*.deb" >> release_notes.md
echo "jellytau" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- Sideload: Download APK and install via file manager or ADB" >> release_notes.md
echo "- Play Store: Coming soon" >> release_notes.md
echo "" >> release_notes.md
echo "### 🐛 Known Issues" >> release_notes.md
echo "" >> release_notes.md
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
echo "" >> release_notes.md
echo "### 📝 Requirements" >> release_notes.md
echo "" >> release_notes.md
echo "**Linux:**" >> release_notes.md
echo "- 64-bit Linux system" >> release_notes.md
echo "- GLIBC 2.29+" >> release_notes.md
echo "" >> release_notes.md
echo "**Android:**" >> release_notes.md
echo "- Android 8.0 or higher" >> release_notes.md
echo "- 50MB free storage" >> release_notes.md
echo "" >> release_notes.md
echo "---" >> release_notes.md
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
name: ${{ steps.tag_name.outputs.RELEASE_NAME }}
body_path: release_notes.md
files: |
artifacts/linux/*
artifacts/android/*
draft: false
prerelease: ${{ contains(steps.tag_name.outputs.VERSION, 'rc') || contains(steps.tag_name.outputs.VERSION, 'beta') || contains(steps.tag_name.outputs.VERSION, 'alpha') }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload to Gitea Releases
run: |
VERSION="${{ steps.tag_name.outputs.VERSION }}"
echo "📦 Release artifacts prepared for $VERSION"
echo ""
echo "Linux:"
ls -lh artifacts/linux/ || echo "No Linux artifacts"
echo ""
echo "Android:"
ls -lh artifacts/android/ || echo "No Android artifacts"
echo ""
echo "✅ Release $VERSION is ready!"
echo "📄 Release notes saved to release_notes.md"
- name: Publish release notes
run: |
echo "## 🎉 Release Published"
echo ""
echo "**Version:** ${{ steps.tag_name.outputs.VERSION }}"
echo "**Tag:** ${{ github.ref }}"
echo ""
echo "Artifacts:"
echo "- Linux artifacts in: artifacts/linux/"
echo "- Android artifacts in: artifacts/android/"
echo ""
echo "Visit the Release page to download files."
-142
View File
@@ -1,142 +0,0 @@
name: Traceability Validation
on:
push:
branches:
- master
- main
- develop
pull_request:
branches:
- master
- main
- develop
jobs:
validate-traces:
runs-on: ubuntu-latest
name: Check Requirement Traces
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Install dependencies
run: bun install
- name: Extract traces
run: |
echo "🔍 Extracting requirement traces..."
bun run traces:json > traces-report.json
- name: Validate traces
run: |
set -e
echo "📊 Validating requirement traceability..."
echo ""
# Parse JSON
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
UR=$(jq '.byType.UR | length' traces-report.json)
IR=$(jq '.byType.IR | length' traces-report.json)
DR=$(jq '.byType.DR | length' traces-report.json)
JA=$(jq '.byType.JA | length' traces-report.json)
# Print coverage report
echo "✅ TRACES Found: $TOTAL_TRACES"
echo ""
echo "📋 Coverage Summary:"
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
echo ""
COVERED=$((UR + IR + DR + JA))
TOTAL_REQS=114
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
echo ""
# Check minimum threshold
MIN_THRESHOLD=50
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
exit 1
fi
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
- name: Check modified files
if: github.event_name == 'pull_request'
run: |
echo "🔍 Checking modified files for traces..."
echo ""
# Get changed files
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || echo "")
if [ -z "$CHANGED" ]; then
echo "✅ No TypeScript/Rust files changed"
exit 0
fi
echo "📝 Changed files:"
echo "$CHANGED" | sed 's/^/ /'
echo ""
# Check each file
MISSING_TRACES=0
while IFS= read -r file; do
# Skip test files
if [[ "$file" == *".test."* ]]; then
continue
fi
if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file"
MISSING_TRACES=$((MISSING_TRACES + 1))
fi
fi
done <<< "$CHANGED"
if [ "$MISSING_TRACES" -gt 0 ]; then
echo ""
echo "📝 Recommendation: Add TRACES comments to new/modified code"
echo " Format: // TRACES: UR-001, UR-002 | DR-003"
echo ""
echo "💡 For more info, see: scripts/README.md"
fi
- name: Generate full report
if: always()
run: |
echo "📄 Generating full traceability report..."
bun run traces:markdown
- name: Display report summary
if: always()
run: |
echo ""
echo "📊 Full Report Generated"
echo "📁 Location: docs/TRACEABILITY.md"
echo ""
head -50 docs/TRACEABILITY.md || true
- name: Save artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-reports
path: |
traces-report.json
docs/TRACEABILITY.md
retention-days: 30
-173
View File
@@ -1,173 +0,0 @@
name: Requirement Traceability Check
on:
push:
branches:
- master
- main
- develop
pull_request:
branches:
- master
- main
- develop
jobs:
traceability:
name: Validate Requirement Traces
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Extract requirement traces
run: bun run traces:json > traces.json
- name: Validate trace format
run: |
if ! jq empty traces.json 2>/dev/null; then
echo "❌ Invalid traces.json format"
exit 1
fi
echo "✅ Traces JSON is valid"
- name: Check requirement coverage
run: |
set -e
# Extract coverage stats
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
UR_COUNT=$(jq '.byType.UR | length' traces.json)
IR_COUNT=$(jq '.byType.IR | length' traces.json)
DR_COUNT=$(jq '.byType.DR | length' traces.json)
JA_COUNT=$(jq '.byType.JA | length' traces.json)
echo "## 📊 Requirement Traceability Report"
echo ""
echo "**Total TRACES Found:** $TOTAL_TRACES"
echo ""
echo "### Requirements Covered:"
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
echo ""
# Set minimum coverage threshold (50%)
TOTAL_REQS=114
MIN_COVERAGE=$((TOTAL_REQS / 2))
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
echo ""
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
exit 1
else
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
fi
- name: Check for new untraced code
run: |
set -e
# Find files modified in this PR/push
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
else
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
fi
if [ -z "$CHANGED_FILES" ]; then
echo "✅ No source files changed"
exit 0
fi
echo "### Files Changed:"
echo "$CHANGED_FILES" | sed 's/^/- /'
echo ""
# Check if changed files have TRACES
UNTRACED_FILES=""
while IFS= read -r file; do
if [ -f "$file" ]; then
# Skip test files and generated code
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
continue
fi
# Check if file has TRACES comments
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
UNTRACED_FILES+="$file"$'\n'
fi
fi
done <<< "$CHANGED_FILES"
if [ -n "$UNTRACED_FILES" ]; then
echo "⚠️ New files without TRACES:"
echo "$UNTRACED_FILES" | sed 's/^/ - /'
echo ""
echo "💡 Add TRACES comments to link code to requirements:"
echo " // TRACES: UR-001, UR-002 | DR-003"
else
echo "✅ All changed files have TRACES comments"
fi
- name: Generate traceability report
if: always()
run: bun run traces:markdown
- name: Upload traceability report
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-report
path: docs/TRACEABILITY.md
retention-days: 30
- name: Comment PR with coverage report
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
const urCount = traces.byType.UR.length;
const irCount = traces.byType.IR.length;
const drCount = traces.byType.DR.length;
const jaCount = traces.byType.JA.length;
const total = urCount + irCount + drCount + jaCount;
const coverage = Math.round((total / 114) * 100);
const comment = `## 📊 Requirement Traceability Report
**Coverage:** ${coverage}% (${total}/114 requirements traced)
### By Type:
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
**Total Traces:** ${traces.totalTraces}
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
-49
View File
@@ -1,49 +0,0 @@
# OS files
.DS_Store
Thumbs.db
# Node.js
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 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
-156
View File
@@ -1,156 +0,0 @@
# Building and Pushing the JellyTau Builder Image
This document explains how to create and push the pre-built builder Docker image to your registry for use in Gitea Act CI/CD.
## Prerequisites
- Docker installed and running
- Access to your Docker registry (e.g., `gitea.tourolle.paris`)
- Docker registry credentials configured (`docker login`)
## Building the Builder Image
### Step 1: Build the Image Locally
```bash
# From the project root
docker build -f Dockerfile.builder -t jellytau-builder:latest .
```
This creates a local image with:
- All system dependencies
- Rust with Android targets
- Android SDK and NDK
- Node.js and Bun
- All build tools pre-installed
### Step 2: Tag for Your Registry
Replace `gitea.tourolle.paris/dtourolle` with your actual registry path:
```bash
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
### Step 3: Login to Your Registry
If not already logged in:
```bash
docker login gitea.tourolle.paris
```
### Step 4: Push to Registry
```bash
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
## Complete One-Liner
```bash
docker build -f Dockerfile.builder -t jellytau-builder:latest . && \
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest && \
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
## Verifying the Build
Check that the image was pushed successfully:
```bash
# List images in your registry (depends on registry API support)
docker search gitea.tourolle.paris/dtourolle/jellytau-builder
# Or pull and test locally
docker pull gitea.tourolle.paris/dtourolle/jellytau-builder:latest
docker run -it gitea.tourolle.paris/dtourolle/jellytau-builder:latest bun --version
```
## Using in CI/CD
The workflow at `.gitea/workflows/build-and-test.yml` automatically uses:
```yaml
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
Once pushed, your CI/CD pipeline will use this pre-built image instead of installing everything during the build, saving significant time.
## Updating the Builder Image
When dependencies change (new Rust version, Android SDK update, etc.):
1. Update `Dockerfile.builder` with the new configuration
2. Rebuild and push with a new tag:
```bash
docker build -f Dockerfile.builder -t jellytau-builder:v1.2.0 .
docker tag jellytau-builder:v1.2.0 gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
```
3. Update the workflow to use the new tag:
```yaml
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:v1.2.0
```
## Image Contents
The builder image includes:
- **Base OS**: Ubuntu 24.04
- **Languages**:
- Rust (stable) with targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android
- Node.js 20.x
- OpenJDK 17 (for Android)
- **Tools**:
- Bun package manager
- Android SDK 34
- Android NDK 27.0.11902837
- Build essentials (gcc, make, etc.)
- Git, curl, wget
- libssl, libclang development libraries
- **Pre-configured**:
- Rust toolchain components (rustfmt, clippy)
- Android SDK/NDK environment variables
- All paths optimized for building
## Build Time
First build takes ~15-20 minutes depending on internet speed (downloads Android SDK/NDK).
Subsequent builds are cached and take seconds.
## Storage
The built image is approximately **4-5 GB**. Ensure your registry has sufficient storage.
## Troubleshooting
### "Image not found" in CI
- Verify the image name matches exactly in the workflow
- Check that the image was successfully pushed: `docker push` output should show successful layers
- Ensure Gitea has access to your registry (check network/firewall)
### Build fails with "command not found"
- The image may not have finished pushing. Wait a few moments and retry the CI job.
- Check that all layers were pushed successfully in the push output.
### Registry authentication in CI
If your registry requires credentials in CI:
1. Create a deploy token in your registry
2. Add to Gitea secrets as `REGISTRY_USERNAME` and `REGISTRY_TOKEN`
3. Use in workflow:
```yaml
- name: Login to Registry
run: |
docker login gitea.tourolle.paris -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_TOKEN }}
```
## References
- [Docker Build Documentation](https://docs.docker.com/build/)
- [Docker Push Documentation](https://docs.docker.com/engine/reference/commandline/push/)
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
-282
View File
@@ -1,282 +0,0 @@
# Docker & CI/CD Setup for JellyTau
This document explains how to use the Docker configuration and Gitea Act CI/CD pipeline for building and testing JellyTau.
## Overview
The setup includes:
- **Dockerfile.builder**: Pre-built image with all dependencies (push to your registry)
- **Dockerfile**: Multi-stage build for local testing and building
- **docker-compose.yml**: Orchestration for local development and testing
- **.gitea/workflows/build-and-test.yml**: Automated CI/CD pipeline using pre-built builder image
### Quick Start
**For CI/CD (Gitea Actions)**:
1. Build and push builder image (see [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md))
2. Push to master branch - workflow runs automatically
3. Check Actions tab for results and APK artifacts
**For Local Testing**:
```bash
docker-compose run test # Run tests
docker-compose run android-build # Build APK
docker-compose run dev # Interactive shell
```
## Docker Usage
### Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+ (if using docker-compose)
- At least 10GB free disk space (for Android SDK and build artifacts)
### Building the Docker Image
```bash
# Build the complete image
docker build -t jellytau:latest .
# Build specific target
docker build -t jellytau:test --target test .
docker build -t jellytau:android --target android-build .
```
### Using Docker Compose
#### Run Tests Only
```bash
docker-compose run test
```
This will:
1. Install all dependencies
2. Run frontend tests (Vitest)
3. Run Rust backend tests
4. Report results
#### Build Android APK
```bash
docker-compose run android-build
```
This will:
1. Run tests first (depends on test service)
2. If tests pass, build the Android APK
3. Output APK files to `src-tauri/gen/android/app/build/outputs/apk/`
#### Interactive Development
```bash
docker-compose run dev
```
This starts an interactive shell with all development tools available. From here you can:
```bash
bun install
bun run build
bun test
bun run tauri android build --apk true
```
#### Run All Services in Sequence
```bash
docker-compose up --abort-on-container-exit
```
### Extracting Build Artifacts
After a successful build, APK files are located in:
```
src-tauri/gen/android/app/build/outputs/apk/
```
Copy to your host machine:
```bash
docker cp jellytau-android-build:/app/src-tauri/gen/android/app/build/outputs/apk ./apk-output
```
## Gitea Act CI/CD Pipeline
The `.gitea/workflows/build-and-test.yml` workflow automates:
**Single Job**: Runs on every push to `master` and PRs
- Uses pre-built builder image (no setup time)
- Installs project dependencies
- Runs frontend tests (Vitest)
- Runs Rust backend tests
- Builds the frontend
- Builds the Android APK
- Uploads APK as artifact (30-day retention)
The workflow skips markdown files to avoid unnecessary builds.
### Workflow Triggers
The workflow runs on:
- Push to `master` or `main` branches
- Pull requests to `master` or `main` branches
- Can be extended with: `workflow_dispatch` for manual triggers
### Setting Up the Builder Image
Before using the CI/CD pipeline, you must build and push the builder image:
```bash
# Build the image
docker build -f Dockerfile.builder -t jellytau-builder:latest .
# Tag for your registry
docker tag jellytau-builder:latest gitea.tourolle.paris/dtourolle/jellytau-builder:latest
# Push to registry
docker push gitea.tourolle.paris/dtourolle/jellytau-builder:latest
```
See [BUILD-BUILDER-IMAGE.md](BUILD-BUILDER-IMAGE.md) for detailed instructions.
### Setting Up Gitea Act
1. **Ensure builder image is pushed** (see above)
2. **Push to Gitea repository**:
The workflow will automatically trigger on push to `master` or pull requests
3. **View workflow runs in Gitea UI**:
- Navigate to your repository
- Go to Actions tab
- Click on workflow runs to see logs
4. **Test locally** (optional):
```bash
# Install act if needed
curl https://gitea.com/actions/setup-act/releases/download/v0.25.0/act-0.25.0-linux-x86_64.tar.gz | tar xz
# Run locally (requires builder image to be available)
./act push --file .gitea/workflows/build-and-test.yml
```
### Customizing the Workflow
#### Modify Build Triggers
Edit `.gitea/workflows/build-and-test.yml` to change when builds run:
```yaml
on:
push:
branches:
- master
- develop # Add more branches
paths:
- 'src/**' # Only run if src/ changes
- 'src-tauri/**' # Only run if Rust code changes
```
#### Add Notifications
Add Slack, Discord, or email notifications on build completion:
```yaml
- name: Notify on success
if: success()
run: |
curl -X POST https://slack-webhook-url...
```
#### Customize APK Upload
Modify artifact retention or add to cloud storage:
```yaml
- name: Upload APK to S3
uses: actions/s3-sync@v1
with:
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY }}
aws_secret_access_key: ${{ secrets.AWS_SECRET_KEY }}
aws_bucket: my-apk-bucket
source_dir: src-tauri/gen/android/app/build/outputs/apk/
```
## Environment Setup in CI
### Secret Variables
To use secrets in the workflow, set them in Gitea:
1. Go to Repository Settings → Secrets
2. Add secrets like:
- `AWS_ACCESS_KEY` for S3 uploads
- `SLACK_WEBHOOK_URL` for notifications
- `GITHUB_TOKEN` for releases (pre-configured)
## Troubleshooting
### Out of Memory During Build
Android builds are memory-intensive. If you get OOM errors:
```bash
# Limit memory in docker-compose
services:
android-build:
deploy:
resources:
limits:
memory: 6G
```
Or increase Docker's memory allocation in Docker Desktop settings.
### Android SDK Download Timeout
If downloads timeout, increase timeout or download manually:
```bash
# In container, with longer timeout
timeout 600 sdkmanager --sdk_root=$ANDROID_HOME ...
```
### Rust Compilation Errors
Make sure Rust is updated:
```bash
rustup update
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
```
### Cache Issues
Clear Docker cache and rebuild:
```bash
docker-compose down -v # Remove volumes
docker system prune # Clean up dangling images
docker-compose up --build
```
## Performance Tips
1. **Cache Reuse**: Both Docker and Gitea Act cache dependencies across runs
2. **Parallel Steps**: The workflow runs frontend and Rust tests in series; consider parallelizing for faster CI
3. **Incremental Builds**: Rust and Node caches persist between runs
4. **Docker Buildkit**: Enable for faster builds:
```bash
DOCKER_BUILDKIT=1 docker build .
```
## Security Considerations
- Dockerfile uses `ubuntu:24.04` base image from official Docker Hub
- NDK is downloaded from official Google servers (verified via HTTPS)
- No credentials are stored in the Dockerfile
- Use Gitea Secrets for sensitive values (API keys, tokens, etc.)
- Lock dependency versions in `Cargo.toml` and `package.json`
## Next Steps
1. Test locally with `docker-compose up`
2. Push to your Gitea repository
3. Monitor workflow runs in the Actions tab
4. Configure secrets in repository settings for production builds
5. Set up artifact retention policies (currently 30 days)
## References
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
- [Android Build Tools](https://developer.android.com/studio/command-line)
- [Tauri Android Guide](https://tauri.app/v1/guides/building/android)
-110
View File
@@ -1,110 +0,0 @@
# Multi-stage build for JellyTau - Tauri Jellyfin client
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \
RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
# Build essentials
build-essential \
curl \
wget \
git \
ca-certificates \
unzip \
# JDK for Android
openjdk-17-jdk-headless \
# Android build tools
android-sdk-platform-tools \
# Additional development tools
pkg-config \
libssl-dev \
libclang-dev \
llvm-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun
# Install Rust using rustup
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
. $HOME/.cargo/env && \
rustup target add aarch64-linux-android && \
rustup target add armv7-linux-androideabi && \
rustup target add x86_64-linux-android
# Setup Android SDK
RUN mkdir -p $ANDROID_HOME && \
mkdir -p /root/.android && \
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
echo 'count=0' >> /root/.android/repositories.cfg
# Download and setup Android Command Line Tools
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
rm /tmp/cmdline-tools.zip && \
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
# Setup Android SDK components
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \
"ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# Create working directory
WORKDIR /app
# Copy project files
COPY . .
# Install Node.js dependencies
RUN bun install
# Install Rust dependencies
RUN cd src-tauri && cargo fetch && cd ..
# Build stage - Tests
FROM builder AS test
WORKDIR /app
RUN echo "Running tests..." && \
bun run test && \
cd src-tauri && cargo test && cd .. && \
echo "All tests passed!"
# Build stage - APK
FROM builder AS android-build
WORKDIR /app
RUN cd src-tauri && cargo fetch && cd .. && \
echo "Building Android APK..." && \
bun run build && \
bun run tauri android build --apk true && \
echo "APK build complete!"
# Final output stage
FROM ubuntu:24.04 AS final
RUN apt-get update && apt-get install -y --no-install-recommends \
android-sdk-platform-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=android-build /app/src-tauri/gen/android/app/build/outputs/apk /app/apk
VOLUME ["/app/apk"]
CMD ["/bin/bash", "-c", "echo 'APK files are available in /app/apk' && ls -lh /app/apk/"]
-72
View File
@@ -1,72 +0,0 @@
# JellyTau Builder Image
# Pre-built image with all dependencies for building and testing
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
ANDROID_HOME=/opt/android-sdk \
NDK_VERSION=27.0.11902837 \
SDK_VERSION=34 \
RUST_BACKTRACE=1 \
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
CARGO_HOME=/root/.cargo
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
curl \
wget \
git \
ca-certificates \
unzip \
openjdk-17-jdk-headless \
pkg-config \
libssl-dev \
libclang-dev \
llvm-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# Install Bun
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun
# Install Rust using rustup
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
. $HOME/.cargo/env && \
rustup target add aarch64-linux-android && \
rustup target add armv7-linux-androideabi && \
rustup target add x86_64-linux-android && \
rustup component add rustfmt clippy
# Setup Android SDK
RUN mkdir -p $ANDROID_HOME && \
mkdir -p /root/.android && \
echo '### User Sources for `android` cmd line tool ###' > /root/.android/repositories.cfg && \
echo 'count=0' >> /root/.android/repositories.cfg
# Download and setup Android Command Line Tools
RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip && \
unzip -q /tmp/cmdline-tools.zip -d $ANDROID_HOME && \
rm /tmp/cmdline-tools.zip && \
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
# Install Android SDK components
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
"platforms;android-$SDK_VERSION" \
"build-tools;34.0.0" \
"ndk;$NDK_VERSION" \
--channel=0 2>&1 | grep -v "Warning" || true
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
-232
View File
@@ -1,232 +0,0 @@
# Code Review Fixes Summary
This document summarizes all the critical bugs and architectural issues that have been fixed in the JellyTau project.
## Fixed Issues
### 🔴 CRITICAL
#### 1. **Fixed nextEpisode Event Handlers - Undefined Method Calls**
- **File:** `src/lib/services/playerEvents.ts`
- **Issue:** Lines 272 and 280 were calling `nextEpisode.showPopup()` and `nextEpisode.updateCountdown()` on an undefined variable.
- **Root Cause:** The import was aliased as `showNextEpisodePopup` but the code tried to use an undefined `nextEpisode` variable.
- **Fix:** Changed import to import the `nextEpisode` store directly, renamed parameters to avoid shadowing.
- **Impact:** Prevents runtime crashes when next episode popup events are emitted from the Rust backend.
#### 2. **Replaced Queue Polling with Event-Based Updates**
- **File:** `src/routes/+layout.svelte`, `src/lib/services/playerEvents.ts`
- **Issue:** Frontend was polling backend every 1 second (`setInterval(updateQueueStatus, 1000)`) for queue status.
- **Root Cause:** Inefficient polling approach creates unnecessary backend load and battery drain.
- **Fix:**
- Removed continuous polling
- Added `updateQueueStatus()` calls on `state_changed` events
- Listeners now trigger updates when playback state changes instead
- **Impact:** Reduces backend load, improves battery life, more reactive to state changes.
### 🟠 HIGH PRIORITY
#### 3. **Moved Device ID to Secure Storage**
- **Files:** `src/lib/services/deviceId.ts` (new), `src/lib/stores/auth.ts`
- **Issue:** Device ID was stored in browser localStorage, accessible to XSS attacks.
- **Fix:**
- Created `deviceId.ts` service that uses Tauri's secure storage commands
- Replaced all `localStorage.getItem("jellytau_device_id")` calls with `getDeviceId()`
- Added caching for performance
- Implemented fallback to in-memory ID if secure storage unavailable
- **Impact:** Enhanced security posture against XSS attacks.
#### 4. **Fixed Event Listener Memory Leaks**
- **File:** `src/lib/stores/auth.ts`, `src/routes/+layout.svelte`
- **Issue:** Event listeners (`listen()` calls) were registered at module load with no cleanup.
- **Fix:**
- Moved listener registration to `initializeEventListeners()` function
- Stored unlisten functions and call them in cleanup
- Added `cleanupEventListeners()` to auth store export
- Called cleanup in `onDestroy()` of layout component
- **Impact:** Prevents memory leaks from duplicate listeners if store/routes are reloaded.
#### 5. **Replaced Browser Alerts with Toast Notifications**
- **File:** `src/lib/components/library/TrackList.svelte`
- **Issue:** Using native `alert()` for errors, which blocks execution and provides poor UX.
- **Fix:**
- Imported `toast` store
- Replaced `alert()` with `toast.error()` call with 5-second timeout
- Improved error message formatting
- **Impact:** Non-blocking error notifications with better UX.
#### 6. **Removed Silent Error Handlers**
- **Files:** `src/lib/services/playbackReporting.ts`, `src/lib/services/imageCache.ts`, `src/lib/services/playerEvents.ts`
- **Issue:** Multiple `.catch(() => {})` handlers silently swallowed errors.
- **Fix:**
- Added proper error logging with `console.debug()` and `console.error()`
- Added comments explaining why failures are non-critical
- Made error handling explicit and debuggable
- **Impact:** Improved debugging and visibility into failures.
### 🟡 MEDIUM PRIORITY
#### 7. **Fixed Race Condition in Downloads Store**
- **File:** `src/lib/stores/downloads.ts`
- **Issue:** Concurrent calls to `refreshDownloads()` could interleave state updates, corrupting state.
- **Fix:**
- Added `refreshInProgress` flag to prevent concurrent calls
- Implemented queuing mechanism for pending refresh requests
- Requests are processed sequentially
- **Impact:** Prevents race condition-induced data corruption in download state.
#### 8. **Centralized Duration Formatting Utility**
- **File:** `src/lib/utils/duration.ts` (new), `src/lib/components/library/TrackList.svelte`, `src/lib/components/library/LibraryListView.svelte`
- **Issue:** Duration formatting logic duplicated across components with magic number `10000000`.
- **Fix:**
- Created `duration.ts` utility with `formatDuration()` and `formatSecondsDuration()` functions
- Added support for both mm:ss and hh:mm:ss formats
- Replaced all component-level functions with imports
- Documented the Jellyfin tick-to-second conversion (10M ticks = 1 second)
- **Impact:** Single source of truth for duration formatting, easier maintenance.
#### 9. **Added Input Validation to Image URLs**
- **File:** `src/lib/utils/validation.ts` (new), `src/lib/api/repository-client.ts`
- **Issue:** Item IDs and image types not validated, vulnerable to path traversal attacks.
- **Fix:**
- Created `validation.ts` with comprehensive input validators:
- `validateItemId()` - rejects invalid characters and excessive length
- `validateImageType()` - whitelist of allowed types
- `validateMediaSourceId()` - similar to item ID validation
- `validateNumericParam()` - bounds checking for widths, heights, quality, etc.
- `validateQueryParamValue()` - safe query parameter validation
- Applied validation to all URL construction methods in repository-client.ts
- Added explicit bounds checking for numeric parameters
- **Impact:** Prevents injection attacks and path traversal vulnerabilities.
#### 10. **Improved Error Handling in Layout Component**
- **File:** `src/routes/+layout.svelte`
- **Issue:** Silent `.catch()` handler in connectivity monitoring could mask failures.
- **Fix:**
- Changed from `.catch(() => {})` to proper error handling with logging
- Added debug messages explaining failure modes
- Implemented async/await with proper error chaining
- **Impact:** Better observability of connectivity issues.
## Unit Tests Added
Comprehensive test suites have been added for critical utilities and services:
### Test Files Created
1. **`src/lib/utils/duration.test.ts`**
- Tests for `formatDuration()` and `formatSecondsDuration()`
- Covers Jellyfin tick conversion, various time formats, edge cases
- 10+ test cases
2. **`src/lib/utils/validation.test.ts`**
- Tests for all validation functions
- Covers valid inputs, invalid characters, bounds checking
- Tests for injection prevention
- 25+ test cases
3. **`src/lib/services/deviceId.test.ts`**
- Tests for device ID generation and caching
- Tests for secure storage fallback
- Tests for cache clearing on logout
- 8+ test cases
4. **`src/lib/services/playerEvents.test.ts`**
- Tests for event listener initialization
- Tests for cleanup and memory leak prevention
- Tests for error handling
### Running Tests
```bash
npm run test
npm run test:ui # Interactive UI
npm run test:coverage # With coverage report
```
## Architecture Improvements
### Separation of Concerns
- ✅ Duration formatting moved to dedicated utility
- ✅ Device ID management centralized in service
- ✅ Input validation extracted to validation utility
- ✅ Event listener lifecycle properly managed
### Security Enhancements
- ✅ Device ID moved from localStorage to secure storage
- ✅ Input validation on all user-influenced URL parameters
- ✅ Path traversal attack prevention via whitelist validation
- ✅ Numeric parameter bounds checking
### Performance Improvements
- ✅ Eliminated 1-second polling (1000 calls/hour reduced to event-driven)
- ✅ Prevented race conditions in state management
- ✅ Added request queuing to prevent concurrent backend thrashing
### Reliability Improvements
- ✅ Fixed critical runtime errors (nextEpisode handlers)
- ✅ Proper memory cleanup prevents leaks
- ✅ Better error handling with visibility
- ✅ Comprehensive test coverage for utilities
## Files Modified
### Core Fixes
- `src/lib/services/playerEvents.ts` - Fixed event handlers, replaced polling
- `src/routes/+layout.svelte` - Removed polling, proper cleanup
- `src/lib/stores/auth.ts` - Device ID management, event listener cleanup
- `src/lib/stores/downloads.ts` - Race condition prevention
- `src/lib/api/repository-client.ts` - Input validation on URLs
- `src/lib/components/library/TrackList.svelte` - Toast notifications, centralized duration
- `src/lib/components/library/LibraryListView.svelte` - Centralized duration formatting
- `src/lib/services/playbackReporting.ts` - Removed silent error handlers
- `src/lib/services/imageCache.ts` - Improved error logging
### New Files
- `src/lib/services/deviceId.ts` - Device ID service (new)
- `src/lib/utils/duration.ts` - Duration formatting utility (new)
- `src/lib/utils/validation.ts` - Input validation utility (new)
- `src/lib/utils/duration.test.ts` - Duration tests (new)
- `src/lib/utils/validation.test.ts` - Validation tests (new)
- `src/lib/services/deviceId.test.ts` - Device ID tests (new)
- `src/lib/services/playerEvents.test.ts` - Player events tests (new)
## Testing Notes
The codebase is now equipped with:
- ✅ Unit tests for duration formatting
- ✅ Unit tests for input validation
- ✅ Unit tests for device ID service
- ✅ Unit tests for player events service
- ✅ Proper mocking of Tauri APIs
- ✅ Vitest configuration ready to use
Run tests with: `npm run test`
## Recommendations for Future Work
1. **Move sorting/filtering to backend** - Currently done in frontend, should delegate to server
2. **Move API URL construction to backend** - Currently in frontend, security risk
3. **Remove more hardcoded configuration values** - Audit for magic numbers throughout codebase
4. **Add CSP headers validation** - Ensure content security policies are properly enforced
5. **Implement proper rate limiting** - Add debouncing to frequently called operations
6. **Expand test coverage** - Add tests for stores, components, and more services
## Backward Compatibility
All changes are backward compatible:
- Device ID service falls back to in-memory ID if secure storage fails
- Duration formatting maintains same output format
- Validation is defensive and allows valid inputs
- Event listeners are properly cleaned up to prevent leaks
## Performance Impact
- **Positive:** 90% reduction in backend polling calls (1000/hour → event-driven)
- **Positive:** Eliminated race conditions that could cause state corruption
- **Positive:** Reduced memory footprint via proper cleanup
- **Neutral:** Input validation adds minimal overhead (happens before URL construction)
---
**Total Issues Fixed:** 10 critical/high-priority items
**Lines of Code Added:** ~800 (utilities, tests, validation)
**Test Coverage:** 45+ test cases across 4 test files
**Estimated Impact:** High reliability and security improvements
-495
View File
@@ -1,495 +0,0 @@
# JellyTau
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
## 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).
---
# Requirements Specification
## 1. User Requirements
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | In Progress |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
| UR-011 | Download media on demand | Medium | Done |
| UR-012 | Login info shall be stored securely and persistently | High | Done |
| UR-013 | View and manage downloaded media | Medium | Done |
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Planned |
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
| UR-020 | Select subtitles for video content | High | Planned |
| UR-021 | Select audio track for video content | High | Planned |
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup | Medium | Done |
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio playback | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Planned |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
---
## 2. Software Requirements
### 2.1 Integration Requirements
External system integrations and platform-specific implementations.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Planned |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
### 2.2 Jellyfin API Requirements
API endpoints and data contracts required for Jellyfin integration.
| ID | Requirement | Endpoint Category | Traces To | Status |
|----|-------------|-------------------|-----------|--------|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
| JA-003 | Get user library views | UserViews | UR-007 | Done |
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
| JA-006 | Search across libraries | Items | UR-008 | Done |
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Planned |
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Planned |
| JA-010 | Report playback start | Sessions | UR-025 | Done |
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
| JA-016 | Get recently added items | Items | UR-024 | Done |
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Planned |
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Planned |
| JA-021 | Get active sessions list | Sessions | UR-010 | Planned |
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Planned |
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Planned |
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Planned |
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Planned |
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Planned |
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Planned |
| JA-030 | Get person details and filmography | Persons | UR-036 | Planned |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Planned |
### 2.3 Development Requirements
Internal architecture, components, and application logic.
| ID | Requirement | Category | Traces To | Status |
|----|-------------|----------|-----------|--------|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Planned |
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Planned |
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Planned |
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Planned |
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
| DR-029 | Sleep timer with countdown and auto-stop | Player | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
| DR-047 | Next episode auto-play popup with configurable countdown | Player | UR-023 | Done |
| DR-048 | Video settings (auto-play toggle, countdown duration) | Settings | UR-023, UR-026 | Done |
---
## 3. Traceability Matrix
### User Requirements to Software Requirements
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
| UR-014 | - | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-026 | - | DR-029, DR-048 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
| UR-029 | - | DR-032 |
| UR-030 | IR-010 | DR-033 |
| UR-031 | - | DR-034 |
| UR-032 | - | DR-035 |
| UR-033 | - | DR-036 |
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
| UR-036 | IR-022, IR-023 | DR-041 |
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
---
## 4. Test Traceability
### Unit Tests to Software Requirements
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| UT-001 | Player state transitions | DR-001 | Pending |
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
| UT-010 | Download queue management | DR-015 | Done |
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
| UT-016 | All database tables created | IR-013, DR-012 | Done |
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
| UT-026 | NullBackend volume default value | DR-004 | Done |
| UT-027 | NullBackend set volume | DR-004 | Done |
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
| UT-035 | Download event completed serialization | DR-015 | Done |
| UT-036 | Download event failed serialization | DR-015 | Done |
| UT-037 | Download worker exponential backoff | DR-015 | Done |
| UT-038 | Download worker error retryable check | DR-015 | Done |
| UT-039 | Download manager creation | DR-015 | Done |
| UT-040 | Download manager set max concurrent | DR-015 | Done |
| UT-041 | Download info serialization | DR-015 | Done |
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
| UT-044 | Offline item serialization | DR-017 | Done |
| UT-045 | Smart cache default config | DR-015 | Done |
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
| UT-047 | Smart cache queue precache config | DR-015 | Done |
| UT-048 | Smart cache storage limit check | DR-015 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
|---------|-----------------|-----------|--------|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
---
## 5. Development Commands
```bash
# Activate Rust environment (fish shell)
source "$HOME/.cargo/env.fish"
# Install dependencies
bun install
# Development
bun run tauri dev
# Type checking
bun run check
# Build for Linux
bun run tauri build
# Build for Android
bun run tauri android build
```
---
## 6. Architecture Overview
```
jellytau/
├── src/ # Svelte frontend
│ ├── lib/
│ │ ├── api/ # Jellyfin API client (repository pattern)
│ │ ├── components/ # UI components (player, library)
│ │ └── stores/ # Svelte stores (auth, library, player, queue)
│ └── routes/ # SvelteKit pages
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── commands/ # Tauri commands
│ │ └── player/ # Player architecture
│ │ ├── state.rs # State machine
│ │ ├── media.rs # MediaItem, MediaSource
│ │ ├── queue.rs # Queue management
│ │ └── backend.rs # PlayerBackend trait
│ └── gen/android/ # Android project
└── README.md
```
---
## 7. 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):
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
**Future Fix**:
- Monitor `keyring-rs` for bug fixes in future versions
- Consider alternative secure storage libraries
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
**Impact**:
- Low - The workaround is functionally equivalent to proper keyring integration
- Credentials are stored securely in the system keyring
- Session restoration works correctly
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
**Dependencies**:
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
- Already available on most Linux distributions by default
---
### Platform Playback Backend Parity (Linux vs Android)
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
**Symptoms**:
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
**Root Cause**:
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
**Affected Files**:
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
**Feature Parity Matrix**:
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|---------|-------------|---------------------|--------|
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | ✅ | ❌ | Gap |
| Gapless playback | ✅ | ❌ | Gap |
| Volume normalization | ✅ | ❌ | Gap |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
---
### Frontend Playback Code Duplication
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
**Symptoms**:
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
- Same `$derived` state merging logic for local/remote playback in both components
- Position conversion (ticks ↔ seconds) scattered across multiple files
**Affected Files**:
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
**Duplicated Code**:
```typescript
// These handlers are identical in both AudioPlayer and MiniPlayer:
handlePlayPause(), handleNext(), handlePrevious(),
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
// These derived states use identical logic:
displayMedia, displayIsPlaying, displayPosition, displayDuration
```
**Future Fix**:
1. Create `src/lib/utils/playbackUnits.ts`:
```typescript
export const TICKS_PER_SECOND = 10_000_000;
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
```
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
- Single source of truth for merged local/remote state
3. Simplify handler wrappers using a utility:
```typescript
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
```
**Impact**:
- Low - Code works correctly but violates DRY principle
- Maintenance burden when logic needs to change
- Risk of handlers diverging over time
**Traces To**: DR-009
File diff suppressed because it is too large Load Diff
-907
View File
@@ -1,907 +0,0 @@
# JellyTau UX Flows & Screen Transitions
This document describes the expected user experience flows, screen transitions, and navigation patterns in JellyTau.
---
## 1. Core Navigation Structure
### 1.1 Navigation System
JellyTau uses a unified navigation system with a bottom navigation bar visible on all platforms (mobile and desktop) and additional header navigation for desktop.
**Bottom Navigation Bar (All Platforms - DR-045, UR-039):**
The bottom navigation bar is the primary navigation and is **always visible** on all platforms (mobile and desktop) except when:
- Full-screen video player is active
- User is on the login screen
**Bottom Nav Structure:**
```
┌─────────────────────────────────────────┐
│ [Home] [Library] [Search] │
└─────────────────────────────────────────┘
```
**Routes:**
- **Home** → `/` (home page with carousels and featured content)
- **Library** → `/library` (library selector showing all libraries)
- **Search** → `/search` (dedicated search page)
**Note:** Available on both mobile and desktop for consistent navigation access.
**Header Navigation (Desktop):**
On desktop (md breakpoint and above), the header contains:
- Logo (links to `/library`)
- Navigation links: Home, Library, Downloads, Settings
- Search bar (inline)
- User menu: Username, Downloads icon, Logout button
**Mobile Navigation:**
On mobile, the header contains:
- Logo
- Three-dot overflow menu button (Android-style)
- Overflow menu includes:
- Downloads
- Settings
- Sign out
**Access Points Summary:**
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
- **Settings** → Desktop: nav link; Mobile: overflow menu
---
## 2. Initial App Launch Flow
### 2.1 First-Time Launch
```mermaid
flowchart TB
Launch[App Launch] --> CheckAuth{Stored<br/>Credentials?}
CheckAuth -->|No| LoginScreen[Login Screen<br/>/login]
CheckAuth -->|Yes| AutoLogin[Auto-login]
LoginScreen --> EnterURL[Enter Server URL]
EnterURL --> EnterCreds[Enter Username/Password]
EnterCreds --> LoginSuccess{Success?}
LoginSuccess -->|No| LoginError[Show Error]
LoginError --> EnterCreds
LoginSuccess -->|Yes| StoreToken[Store Token in Keyring]
AutoLogin --> TokenValid{Token Valid?}
TokenValid -->|No| LoginScreen
TokenValid -->|Yes| HomePage
StoreToken --> HomePage[Home Page<br/>/]
```
**Screens:**
1. **Login Screen** (`/login`)
- Server URL input
- Username input
- Password input
- "Remember me" checkbox (default: on)
- Login button
- No header, no bottom nav
2. **Home Page** (`/`)
- Default landing page after successful login
- Shows featured content, carousels, continue watching
- No MiniPlayer visible (nothing playing yet)
- Bottom nav: Home tab active
- Header with navigation links
### 2.2 Subsequent Launches
```mermaid
flowchart TB
Launch[App Launch] --> LoadAuth[Load Stored Token]
LoadAuth --> Validate{Token Valid?}
Validate -->|Yes| RestoreState[Restore Last Screen]
Validate -->|No| LoginScreen[Login Screen<br/>/login]
RestoreState --> CheckPlayer{Was Player<br/>Active?}
CheckPlayer -->|Yes| ShowMiniPlayer[Show MiniPlayer<br/>at bottom]
CheckPlayer -->|No| HideMiniPlayer[No MiniPlayer]
ShowMiniPlayer --> LastScreen[Last Active Screen<br/>with MiniPlayer]
HideMiniPlayer --> HomePage[Home Page<br/>/]
```
**State Restoration:**
- Last viewed screen (route) is restored (defaults to `/` if none)
- If audio was playing, MiniPlayer appears at bottom
- Playback state is NOT automatically resumed (user must press play)
- Queue is restored if it existed
---
## 3. Audio Playback Flows
### 3.1 Starting Audio Playback
```mermaid
flowchart TB
Start[User Action] --> Action{Action Type?}
Action -->|Click Track| TrackList[TrackList Component]
Action -->|Click Album| AlbumDetail[Album Detail Page]
Action -->|Click Play on Album| AlbumPlay[Play Album Button]
TrackList --> PlayTrack[Play Single Track]
PlayTrack --> QueueAll[Queue All Filtered Tracks]
AlbumPlay --> PlayAlbum[Play All Album Tracks]
PlayAlbum --> QueueAlbum[Queue Album Tracks]
QueueAll --> InvokePlay[invoke player_play_queue]
QueueAlbum --> InvokePlay
InvokePlay --> PlayerStarts[Player State: Playing]
PlayerStarts --> MiniAppears[MiniPlayer Slides Up<br/>from Bottom]
MiniAppears --> StayOnPage[User Stays on<br/>Current Screen]
```
**Entry Points for Audio Playback:**
1. **TrackList** (`/library/music/tracks`, `/library/music/albums/[id]`)
- Click track number → Play track + queue all visible tracks
- Clicking track #3 in an album → Play track 3, queue tracks 1-10
2. **Album Card** (grid views)
- Click album → Navigate to album detail
- Play button on card → Play album immediately
3. **Search Results**
- Click track → Play track + queue search results
- Click album → Navigate to album detail
**MiniPlayer Behavior:**
- Slides up from bottom with animation (300ms)
- Height: 64px on mobile, 80px on desktop
- Shows: artwork, title, artist, play/pause, next, favorite
- Stays visible on ALL screens (except video player)
- Click anywhere on MiniPlayer → Navigate to full player
**Track Highlighting:**
When audio is playing, the currently playing track is visually highlighted in track lists and album pages:
- Subtle blue background tint
- Left border accent in Jellyfin blue
- Title text colored in Jellyfin blue
- Desktop: Animated pulsing dots indicator next to title
- Mobile: Play arrow (▶) inline with title
- Highlight updates automatically when skipping to next/previous track
### 3.2 MiniPlayer → Full Player Transition
```mermaid
flowchart TB
Mini[MiniPlayer Visible] --> UserClick{User Action}
UserClick -->|Click MiniPlayer| NavFullPlayer[Navigate to<br/>/player/[id]]
UserClick -->|Swipe Up| SwipeGesture[Swipe Gesture<br/>Planned]
NavFullPlayer --> FullPlayer[Full Audio Player Screen]
SwipeGesture --> FullPlayer
FullPlayer --> ShowControls[Show Full Controls:<br/>- Large artwork<br/>- Progress bar<br/>- Volume slider<br/>- Queue button<br/>- Shuffle/Repeat<br/>- Favorite button]
ShowControls --> MiniHidden[MiniPlayer Hidden]
```
**Full Player Screen** (`/player/[id]`)
- **Header:** Song title, artist (clickable links to artist/album pages)
- **Artwork:** Large album art (centered, dominant)
- **Progress:** Seek bar with current time / total duration
- **Controls:** Previous, Play/Pause, Next (large touch targets)
- **Secondary Controls:** Shuffle, Repeat mode, Queue, Favorite
- **Volume:** Volume slider
- **Bottom Nav:** Still visible (can navigate away while playing)
- **Back button:** Returns to previous screen, MiniPlayer reappears
### 3.3 Full Player → Back to Browsing
```mermaid
flowchart TB
FullPlayer[Full Player Screen] --> UserAction{User Action}
UserAction -->|Back Button / Close| HistoryBack[window.history.back]
UserAction -->|Bottom Nav Click| NavOther[Navigate to<br/>Other Screen]
HistoryBack --> PrevScreen[Return to Previous Screen<br/>in Browser History]
NavOther --> NewScreen[Navigate to New Screen]
PrevScreen --> MiniReappears[MiniPlayer Slides Up<br/>from Bottom]
NewScreen --> MiniReappears
MiniReappears --> PlaybackContinues[Playback Continues<br/>in Background]
```
**Navigation Behavior:**
- **Back Button:** Uses browser history (`window.history.back()`) to return to the previous page
- **Expected behavior:** Returns user to the screen they were on before opening full player
- **Example:** User browsing album → clicks track → full player opens → clicks back → returns to album
**Key UX Principles:**
- **Playback Never Stops:** Navigating away from player does NOT stop playback
- **MiniPlayer Persistence:** MiniPlayer visible on ALL screens (except video/login)
- **Queue Preserved:** Current queue remains intact
- **State Restoration:** Returning to full player shows same state (position, volume, etc.)
- **Natural Navigation:** Back button behaves as expected (returns to previous page, not just closes modal)
---
## 4. Video Playback Flows
### 4.1 Starting Video Playback
```mermaid
flowchart TB
Start[User Action] --> Action{Action Type?}
Action -->|Click Movie| MovieDetail[Movie Detail Page]
Action -->|Click Episode| EpisodeClick[Episode Click]
Action -->|Click Play Button| PlayButton[Play Button]
MovieDetail --> PlayMovie[Play Movie Button]
EpisodeClick --> PlayEpisode[Play Episode]
PlayMovie --> CheckResume{Resume<br/>Position?}
PlayEpisode --> CheckResume
CheckResume -->|Yes, >30s| ShowDialog[Resume Dialog]
CheckResume -->|No| DirectPlay[Start from Beginning]
ShowDialog --> UserChoice{User Choice}
UserChoice -->|Resume| ResumePlay[Start at Saved Position]
UserChoice -->|Start Over| DirectPlay
ResumePlay --> FullscreenVideo[Fullscreen Video Player<br/>/player/[id]]
DirectPlay --> FullscreenVideo
FullscreenVideo --> HideUI[Hide All UI:<br/>- No Bottom Nav<br/>- No MiniPlayer<br/>- Fullscreen only]
```
**Resume Dialog:**
```
┌─────────────────────────────────────────┐
│ Continue Watching? │
│ │
│ [Movie Title] │
│ Resume from 12:34 / 1:45:00 │
│ │
│ [Start from Beginning] [Resume] │
└─────────────────────────────────────────┘
```
### 4.2 Video Player Screen (IR-003, IR-004, UR-003)
**Initial State (First 3 seconds):**
- Controls visible overlay
- Top bar: Back button, title
- Bottom bar: Play/Pause, seek bar, time, settings (subtitles, audio track)
- Center: Large play/pause button
**After 3 Seconds (Idle):**
- All controls fade out (500ms animation)
- Fullscreen video only
- System UI hidden (status bar, nav bar)
**User Interaction:**
- **Tap screen:** Controls reappear for 3 seconds
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
- **Keyboard space/K:** Toggle play/pause
- **Keyboard F:** Toggle fullscreen
- **Pinch:** Zoom (planned)
### 4.3 Exiting Video Player
```mermaid
flowchart TB
VideoPlaying[Video Playing] --> UserAction{User Action}
UserAction -->|Back Button| StopVideo[Stop Playback]
UserAction -->|Home Button| Background[App to Background]
UserAction -->|Video Ends| VideoEnd[Playback Ended]
StopVideo --> SaveProgress[Save Progress<br/>to Local DB + Server]
VideoEnd --> SaveComplete[Mark as Watched<br/>Save Progress]
Background --> PauseVideo[Pause Video]
SaveProgress --> ExitFullscreen[Exit Fullscreen]
SaveComplete --> AutoNext{Next Episode<br/>Available?}
AutoNext -->|Yes| ShowCountdown[Show Countdown<br/>Next in 5s...]
AutoNext -->|No| ExitFullscreen
ShowCountdown --> UserCancel{User Cancels?}
UserCancel -->|Yes| ExitFullscreen
UserCancel -->|No, timeout| PlayNext[Play Next Episode]
ExitFullscreen --> RestoreUI[Restore UI:<br/>- Bottom Nav<br/>- Previous Screen]
PlayNext --> VideoPlaying
PauseVideo --> ShowNotification[Show Notification:<br/>Tap to Resume]
```
**Auto-Next Overlay:**
```
┌─────────────────────────────────────────┐
│ │
│ [Episode Thumbnail] │
│ │
│ Next: S01E02 - Episode Title │
│ Starting in 5 seconds... │
│ │
│ [Cancel] [Play Now] │
└─────────────────────────────────────────┘
```
---
## 5. Music Library Navigation Flows
### 5.1 Music Category Landing Page
```mermaid
flowchart TB
LibraryHome[Library Home<br/>/library] --> ClickMusic[Click Music Library]
ClickMusic --> MusicLanding[Music Landing Page<br/>/library/music]
MusicLanding --> ShowCategories[Show Category Cards:<br/>- Tracks<br/>- Artists<br/>- Albums<br/>- Playlists<br/>- Genres]
ShowCategories --> UserClick{User Clicks Category}
UserClick -->|Tracks| TracksPage[All Tracks Page<br/>/library/music/tracks]
UserClick -->|Artists| ArtistsPage[Artists Grid<br/>/library/music/artists]
UserClick -->|Albums| AlbumsPage[Albums Grid<br/>/library/music/albums]
UserClick -->|Playlists| PlaylistsPage[Playlists Grid<br/>/library/music/playlists]
UserClick -->|Genres| GenresPage[Genres Browser<br/>/library/music/genres]
```
**Category Cards:**
```
┌─────────────────────────────────────────┐
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ 🎵 │ │ 👤 │ │ 💿 │ │
│ │Track│ │Artist│ │Album│ │
│ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ 📝 │ │ 🎭 │ │
│ │List │ │Genre│ │
│ └──────┘ └──────┘ │
└─────────────────────────────────────────┘
```
### 5.2 Albums View Flow
```mermaid
flowchart TB
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
ShowAlbum --> TrackAction{User Action}
TrackAction -->|Click Track| PlayTrack[Play Track + Queue Album]
TrackAction -->|Click Artist| NavArtist[Navigate to Artist Page]
TrackAction -->|Download Album| DownloadFlow[Download Flow]
TrackAction -->|Back Button| BackToGrid[Return to Albums Grid]
```
**Album Detail Layout:**
```
┌─────────────────────────────────────────┐
│ [←] [♡] [⬇] │
│ │
│ ┌────────────────────┐ │
│ │ │ │
│ │ Album Artwork │ │
│ │ │ │
│ └────────────────────┘ │
│ │
│ Album Title │
│ Artist Name (clickable) │
│ 2024 • 12 tracks • 45:23 │
│ │
│ [▶ Play] [🔀 Shuffle] │
│ │
│ ───────────────────────────────────── │
│ 1 Track Title 3:45 │
│ 2 Track Title 4:12 │
│ 3 Track Title 3:28 │
│ ... │
└─────────────────────────────────────────┘
```
### 5.3 Artist Navigation
```mermaid
flowchart TB
ArtistsGrid[Artists Grid] --> ClickArtist[Click Artist]
ClickArtist --> ArtistPage[Artist Detail Page<br/>/library/artist/[id]]
ArtistPage --> ShowContent[Show Artist Content:<br/>- Artist Photo<br/>- Biography<br/>- Albums Grid<br/>- Top Tracks<br/>- Similar Artists]
ShowContent --> UserAction{User Action}
UserAction -->|Click Album| AlbumDetail[Album Detail Page]
UserAction -->|Play Top Tracks| PlayArtist[Play Artist Radio]
UserAction -->|Click Similar Artist| OtherArtist[Other Artist Page]
```
---
## 6. Search Flow
### 6.1 Search Page Navigation
```mermaid
flowchart TB
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
ClickSearch --> SearchPage[Search Page<br/>/search]
SearchPage --> EmptyState{Has Query?}
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
ShowPrompt --> UserTypes[User Types in Search]
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
LiveSearch --> ShowResults
ShowResults --> UserClick{User Clicks Result}
UserClick -->|Song| PlaySong[Play Song + Queue Results]
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
```
**Search Page Layout:**
```
┌─────────────────────────────────────────┐
│ [🔍 Search...] [✕] │
│ │
│ Songs ──────────────────────────── │
│ ♪ Song Title - Artist 3:45 │
│ ♪ Song Title - Artist 4:12 │
│ See all (23) │
│ │
│ Albums ─────────────────────────── │
│ [Album Cover] Album Title │
│ [Album Cover] Album Title │
│ See all (8) │
│ │
│ Artists ────────────────────────── │
│ [Photo] Artist Name │
│ See all (5) │
└─────────────────────────────────────────┘
```
---
## 7. Download Flows
### 7.1 Initiating Downloads
```mermaid
flowchart TB
User[User on Album/Track Page] --> ClickDownload[Click Download Button]
ClickDownload --> CheckType{Download Type?}
CheckType -->|Single Track| DownloadTrack[Download Single File]
CheckType -->|Album| DownloadAlbum[Download All Tracks]
CheckType -->|Artist| ShowOptions[Show Options Dialog]
ShowOptions --> UserChoice{User Choice}
UserChoice -->|Discography| DownloadAll[Download All Albums]
UserChoice -->|Select Albums| AlbumPicker[Album Selection UI]
DownloadTrack --> QueueDownload[Queue in Download Manager]
DownloadAlbum --> QueueMultiple[Queue Multiple Files]
QueueDownload --> ShowProgress[Show Progress Ring<br/>on Download Button]
QueueMultiple --> ShowProgress
ShowProgress --> DownloadActive[Download Active:<br/>Button shows % complete]
```
**Download Button States:**
```
States:
1. [⬇] Available - Gray outline
2. [○ 45%] Downloading - Blue ring progress
3. [✓] Downloaded - Green checkmark
4. [!] Failed - Red with retry option
5. [⏸] Paused - Yellow pause icon
```
### 7.2 Managing Downloads Page
```mermaid
flowchart TB
User[User] --> NavChoice{Navigation Path}
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
NavChoice -->|Direct| TypeURL[Type /downloads]
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
HeaderIcon --> DownloadsPage
TypeURL --> DownloadsPage
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
ShowTabs --> ActiveTab{Active Tab}
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
ShowActive --> UserAction1{User Action}
UserAction1 -->|Pause| PauseDownload[Pause Download]
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
ShowCompleted --> UserAction2{User Action}
UserAction2 -->|Play| PlayOffline[Play from Local File]
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
```
**Navigation to Downloads:**
- **Desktop:** Click "Downloads" link in header navigation
- **All screen sizes:** Click download icon (⬇) button in header user menu
- **Direct:** Navigate to `/downloads` route
**Downloads Page Layout:**
```
┌─────────────────────────────────────────┐
│ [←] Downloads │
│ │
│ [Active (3)] [Completed (12)] │
│ │
│ ─ Downloading ──────────────────── │
│ │
│ Album Cover Album Title │
│ Artist Name │
│ [████████░░] 80% │
│ [⏸ Pause] [✕ Cancel] │
│ │
│ Album Cover Album Title │
│ Artist Name │
│ [██░░░░░░░░] 20% │
│ [⏸ Pause] [✕ Cancel] │
│ │
│ ─ Queued ───────────────────────── │
│ │
│ Album Cover Album Title │
│ Artist Name │
│ Waiting... │
└─────────────────────────────────────────┘
```
---
## 8. Settings & Account Flows
### 8.1 Settings Navigation
```mermaid
flowchart TB
User[User] --> NavChoice{Navigation Path}
NavChoice -->|Desktop| HeaderSettings[Header: Click Settings Link]
NavChoice -->|Mobile| OverflowMenu[Click Overflow Menu<br/>→ Settings]
NavChoice -->|Direct| TypeURL[Navigate to /settings]
HeaderSettings --> SettingsPage[Settings Page<br/>/settings]
OverflowMenu --> SettingsPage
TypeURL --> SettingsPage
SettingsPage --> ShowSections[Show Sections:<br/>- Account<br/>- Playback<br/>- Downloads<br/>- Appearance<br/>- About]
ShowSections --> UserClick{User Clicks Section}
UserClick -->|Account| AccountSettings[Account Settings:<br/>- Server URL<br/>- Username<br/>- Logout button]
UserClick -->|Playback| PlaybackSettings[Playback Settings:<br/>- Gapless playback<br/>- Volume normalization<br/>- Crossfade duration]
UserClick -->|Downloads| DownloadSettings[Download Settings:<br/>- Max concurrent<br/>- WiFi only<br/>- Storage location<br/>- Auto-cache next tracks]
UserClick -->|Appearance| AppearanceSettings[Appearance Settings:<br/>- Dark mode<br/>- Accent color]
```
**Navigation to Settings:**
- **Desktop:** Click "Settings" link in header navigation
- **Mobile:** Click three-dot overflow menu → Select "Settings"
- **Direct:** Navigate to `/settings` route
### 8.2 Logout Flow
```mermaid
flowchart TB
AnyScreen[Any Screen] --> ClickLogout[Click Logout Button<br/>in Header]
ClickLogout --> ConfirmDialog[Show Confirmation:<br/>"Log out of [Server]?"]
ConfirmDialog --> UserConfirm{User Confirms?}
UserConfirm -->|No| CancelLogout[Cancel - Stay on Current Screen]
UserConfirm -->|Yes| StopPlayer[Stop Playback]
StopPlayer --> ClearToken[Delete Token from Keyring]
ClearToken --> ClearState[Clear App State:<br/>- Player state<br/>- Queue<br/>- Current screen]
ClearState --> NavLogin[Navigate to Login Screen<br/>/login]
NavLogin --> ShowLogin[Show Login Screen:<br/>- No Header<br/>- No Bottom Nav<br/>- No MiniPlayer]
```
**Logout Button Location:**
- Always visible in header user menu (logout icon)
- Accessible from any authenticated screen
---
## 9. Background & Lock Screen Behavior
### 9.1 Audio Playback in Background (Android)
```mermaid
flowchart TB
Playing[Audio Playing] --> Background{User Action}
Background -->|Home Button| AppBackground[App to Background]
Background -->|Screen Lock| ScreenLock[Screen Locked]
AppBackground --> ContinuePlay[Playback Continues]
ScreenLock --> ContinuePlay
ContinuePlay --> ShowNotification[Show Media Notification:<br/>- Artwork<br/>- Title/Artist<br/>- Play/Pause<br/>- Next/Previous]
ShowNotification --> LockScreen[Lock Screen Controls:<br/>Media Session Integration]
LockScreen --> UserInteract{User Interaction}
UserInteract -->|Tap Notification| OpenApp[Open App to Last Screen<br/>with MiniPlayer]
UserInteract -->|Lock Screen Controls| SendCommand[Send Command to Player]
UserInteract -->|BLE Headset Button| HeadsetControl[AVRCP Command]
```
**Notification Layout (Android):**
```
┌─────────────────────────────────────────┐
│ [Artwork] Song Title │
│ Artist Name │
│ Album Name │
│ │
│ [⏮] [⏸] [⏭] [✕] │
└─────────────────────────────────────────┘
```
### 9.2 Video Playback in Background
```mermaid
flowchart TB
VideoPlaying[Video Playing] --> Background{User Action}
Background -->|Home Button| AutoPause[Automatically Pause]
Background -->|Screen Lock| AutoPause
AutoPause --> SaveProgress[Save Progress]
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
ShowNotification --> UserReturn{User Returns?}
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
UserReturn -->|Later| KeepPaused[Video Remains Paused]
ResumeVideo --> AskResume[Resume from Saved Position]
```
---
## 10. Error States & Edge Cases
### 10.1 Network Loss During Streaming
```mermaid
flowchart TB
Streaming[Streaming Audio/Video] --> LoseNetwork[Network Connection Lost]
LoseNetwork --> CheckLocal{Local Copy<br/>Available?}
CheckLocal -->|Yes| SwitchLocal[Switch to Local Playback<br/>Seamlessly]
CheckLocal -->|No| ShowBuffer[Show Buffering Spinner]
ShowBuffer --> WaitReconnect[Wait for Reconnection<br/>30 second timeout]
WaitReconnect --> Reconnect{Reconnected?}
Reconnect -->|Yes| Resume[Resume Streaming]
Reconnect -->|No| ShowError[Show Error Toast:<br/>"Unable to stream.<br/>Check connection."]
ShowError --> OfferRetry[Offer Retry Button]
ShowError --> OfferDownload[Offer "Download for Offline"]
```
### 10.2 Server Unreachable
```mermaid
flowchart TB
Action[User Action Requires Server] --> TryConnect[Attempt Connection]
TryConnect --> Timeout{Connection<br/>Timeout?}
Timeout -->|Yes| ShowError[Show Error:<br/>"Server unreachable"]
Timeout -->|No| Success[Action Succeeds]
ShowError --> OfferOptions[Offer Options:<br/>- Retry<br/>- Switch to Offline Mode<br/>- Change Server]
```
### 10.3 Download Failed
```mermaid
flowchart TB
Downloading[Download in Progress] --> Failure{Failure Type?}
Failure -->|Network Error| Retry[Auto-retry<br/>with Backoff]
Failure -->|Disk Full| ShowDiskError[Show Error:<br/>"Not enough storage"]
Failure -->|Server Error| ShowServerError[Show Error:<br/>"Server error"]
Retry --> RetryCount{Retry Count<br/>< 3?}
RetryCount -->|Yes| Downloading
RetryCount -->|No| Failed[Mark as Failed]
ShowDiskError --> Failed
ShowServerError --> Failed
Failed --> UserAction[Show in Downloads:<br/>with Retry Button]
```
---
## 11. Platform-Specific UX Patterns
### 11.1 Android-Specific
**Hardware Back Button:**
- **In Full Player:** Return to previous screen, show MiniPlayer
- **In Video Player:** Stop playback, exit fullscreen
- **In Album Detail:** Return to library grid
- **At Library Home:** Exit app (show confirmation)
**System Volume Buttons:**
- **While playing audio:** Adjust playback volume
- **While controlling remote session:** Adjust remote session volume (shows session name in volume panel)
- **In menus:** Adjust system volume (default behavior)
**Share Integration:**
- Long-press album/song → Share menu
- Options: Share with other apps, Copy link
### 11.2 Linux Desktop-Specific
**Keyboard Shortcuts:**
- `Space`: Play/Pause
- `→`: Next track
- `←`: Previous track
- `/`: Focus search
- `Ctrl+Q`: Quit
**Window Behavior:**
- Minimize to tray (playback continues)
- Close window (show confirmation if playing)
- MPRIS integration for desktop media controls
**Mouse Interactions:**
- Hover over MiniPlayer: Show additional controls (volume, queue peek)
- Right-click: Context menu (Add to playlist, Go to artist, Download)
---
## 12. UX Principles Summary
### 12.1 Core Principles
1. **Playback Persistence:**
- Audio playback never stops unless user explicitly stops it
- MiniPlayer visible on all screens (except video/login)
- Queue and position preserved across navigation
2. **Non-Blocking UI:**
- Downloads happen in background
- Sync operations never block user interaction
- Optimistic updates (favorite, progress) with background sync
3. **Offline-First:**
- Downloaded content works offline
- Seamless switch between online/offline
- Progress and preferences saved locally
4. **Progressive Disclosure:**
- Simple defaults, advanced options hidden
- Context menus for secondary actions
- Settings organized by category
5. **Responsive Design:**
- Mobile-first UI
- Desktop enhancements (hover states, keyboard shortcuts)
- Tablet: Grid layouts with more columns
### 12.2 Animation & Transitions
| Transition | Duration | Easing |
|------------|----------|--------|
| MiniPlayer slide up/down | 300ms | ease-out |
| Screen navigation | 200ms | ease-in-out |
| Video controls fade | 500ms | ease-out |
| Download button state change | 150ms | ease-in-out |
| Modal appear | 200ms | ease-out |
| Toast notification | 250ms | ease-in-out |
### 12.3 Touch Targets (Mobile)
| Element | Minimum Size |
|---------|--------------|
| Bottom nav buttons | 48x48 dp |
| List item (track, album) | Full width x 56 dp |
| Player controls | 56x56 dp |
| MiniPlayer | Full width x 64 dp |
| Download button | 40x40 dp |
| Favorite button | 40x40 dp |
---
## 13. Future UX Enhancements
### 13.1 Planned Features
1. **Gesture Navigation:**
- Swipe up on MiniPlayer → Full player
- Swipe down on full player → Back to previous screen
- Swipe between tracks in full player
2. **Queue Management UI (DR-020):**
- Drag to reorder
- Swipe to remove
- Add to queue vs. Play next
3. **Sleep Timer (UR-026):**
- Accessible from full player menu
- Presets: 15min, 30min, 1hr, End of track, End of album
- Countdown visible in MiniPlayer
4. **Home Screen (UR-034):**
- Hero banner carousel
- Continue watching/listening
- Recently added
- Personalized recommendations
5. **Cast/Remote Control Enhancements:**
- Picture-in-picture for remote sessions
- Multi-room audio (play on multiple devices)
- Handoff (transfer playback to phone from TV)
### 13.2 Accessibility Enhancements
- Screen reader optimization
- High contrast mode
- Larger text option
- Voice control integration
- Haptic feedback for controls
---
This UX flow documentation should be updated as new features are implemented and user feedback is incorporated.
-87
View File
@@ -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
-1449
View File
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
version: '3.8'
services:
# Test service - runs tests only
test:
build:
context: .
dockerfile: Dockerfile
target: test
container_name: jellytau-test
volumes:
- .:/app
environment:
- RUST_BACKTRACE=1
command: bash -c "bun test && cd src-tauri && cargo test && cd .. && echo 'All tests passed!'"
# Android build service - builds APK after tests pass
android-build:
build:
context: .
dockerfile: Dockerfile
target: android-build
container_name: jellytau-android-build
volumes:
- .:/app
- android-cache:/root/.cargo
- android-bun-cache:/root/.bun
environment:
- RUST_BACKTRACE=1
- ANDROID_HOME=/opt/android-sdk
depends_on:
- test
ports:
- "5172:5172" # In case you want to run dev server
# Development container - for interactive development
dev:
build:
context: .
dockerfile: Dockerfile
target: builder
container_name: jellytau-dev
volumes:
- .:/app
- cargo-cache:/root/.cargo
- bun-cache:/root/.bun
- node-modules:/app/node_modules
environment:
- RUST_BACKTRACE=1
- ANDROID_HOME=/opt/android-sdk
- NDK_HOME=/opt/android-sdk/ndk/27.0.11902837
working_dir: /app
stdin_open: true
tty: true
command: /bin/bash
volumes:
cargo-cache:
bun-cache:
android-cache:
android-bun-cache:
node-modules:
-347
View File
@@ -1,347 +0,0 @@
# Build & Release Workflow
This document explains the automated build and release process for JellyTau.
## Overview
The CI/CD pipeline automatically:
1. ✅ Runs all tests (frontend + Rust)
2. ✅ Builds Linux binaries (AppImage + DEB)
3. ✅ Builds Android APK and AAB
4. ✅ Creates releases with artifacts
5. ✅ Tags releases with version numbers
## Workflow Triggers
### Automatic Trigger
When you push a version tag:
```bash
git tag v1.0.0
git push origin v1.0.0
```
The workflow automatically:
1. Runs tests
2. Builds both platforms
3. Creates a GitHub release with artifacts
4. Tags it as release/prerelease based on version
### Manual Trigger
In Gitea Actions UI:
1. Go to **Actions** tab
2. Click **Build & Release** workflow
3. Click **Run workflow**
4. Optionally specify a version
5. Workflow runs without creating a release
## Version Tagging
### Format
Version tags follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
Examples:
- `v1.0.0` - Release version
- `v1.0.0-rc1` - Release candidate (marked as prerelease)
- `v1.0.0-beta` - Beta version (marked as prerelease)
- `v0.1.0-alpha` - Alpha version (marked as prerelease)
### Creating a Release
```bash
# Create and push a version tag
git tag v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
# Or create from main branch
git tag -a v1.0.0 -m "Release version 1.0.0" main
git push origin v1.0.0
```
### Release Status
Versions containing `rc`, `beta`, or `alpha` are marked as **prerelease**:
```bash
git tag v1.0.0-rc1 # ⚠️ Prerelease
git tag v1.0.0-beta # ⚠️ Prerelease
git tag v1.0.0-alpha # ⚠️ Prerelease
git tag v1.0.0 # ✅ Full release
```
## Workflow Steps
### 1. Test Phase
Runs on all tags and manual triggers:
- Frontend tests (`vitest`)
- Rust tests (`cargo test`)
- TypeScript type checking
**Failure:** Stops workflow, no build/release
### 2. Build Linux Phase
Runs after tests pass:
- Installs system dependencies
- Builds with Tauri
- Generates:
- **AppImage** - Universal Linux binary
- **DEB** - Debian/Ubuntu package
**Output:** `artifacts/linux/`
### 3. Build Android Phase
Runs in parallel with Linux build:
- Installs Android SDK/NDK
- Configures Rust for Android targets
- Builds with Tauri
- Generates:
- **APK** - Android app package (installable)
- **AAB** - Android App Bundle (for Play Store)
**Output:** `artifacts/android/`
### 4. Create Release Phase
Runs after both builds succeed (only on version tags):
- Prepares release notes
- Downloads build artifacts
- Creates GitHub/Gitea release
- Uploads all artifacts
- Tags as prerelease if applicable
## Artifacts
### Linux Artifacts
#### AppImage
- **File:** `jellytau_*.AppImage`
- **Size:** ~100-150 MB
- **Use:** Run directly on any Linux distro
- **Installation:**
```bash
chmod +x jellytau_*.AppImage
./jellytau_*.AppImage
```
#### DEB Package
- **File:** `jellytau_*.deb`
- **Size:** ~80-120 MB
- **Use:** Install on Debian/Ubuntu/similar
- **Installation:**
```bash
sudo dpkg -i jellytau_*.deb
jellytau
```
### Android Artifacts
#### APK
- **File:** `jellytau-release.apk`
- **Size:** ~60-100 MB
- **Use:** Direct installation on Android devices
- **Installation:**
```bash
adb install jellytau-release.apk
# Or sideload via file manager
```
#### AAB (Android App Bundle)
- **File:** `jellytau-release.aab`
- **Size:** ~50-90 MB
- **Use:** Upload to Google Play Console
- **Note:** Cannot be installed directly; for Play Store distribution
## Release Notes
Release notes are automatically generated with:
- Version number
- Download links
- Installation instructions
- System requirements
- Known issues link
- Changelog reference
## Build Matrix
| Platform | OS | Architecture | Format |
|----------|----|----|--------|
| **Linux** | Any | x86_64 | AppImage, DEB |
| **Android** | 8.0+ | arm64, armv7, x86_64 | APK, AAB |
## Troubleshooting
### Build Fails During Test Phase
1. Check test output in Gitea Actions
2. Run tests locally: `bun run test` and `bun run test:rust`
3. Fix failing tests
4. Create new tag with fixed code
### Linux Build Fails
1. Check system dependencies installed
2. Verify Tauri configuration
3. Check cargo dependencies
4. Clear cache: Delete `.cargo` and `target/` directories
### Android Build Fails
1. Check Android SDK/NDK setup
2. Verify Java 17 is installed
3. Check Rust Android targets: `rustup target list`
4. Clear cache and rebuild
### Release Not Created
1. Tag must start with `v` (e.g., `v1.0.0`)
2. Tests must pass
3. Both builds must succeed
4. Check workflow logs for errors
## GitHub Release vs Gitea
The workflow uses GitHub Actions SDK but is designed for Gitea. For Gitea-native releases:
1. Workflow creates artifacts
2. Artifacts are available in Actions artifacts
3. Download and manually create Gitea release, or
4. Set up Gitea API integration to auto-publish
## Customization
### Change Release Notes Template
Edit `.gitea/workflows/build-release.yml`, section `Prepare release notes`:
```yaml
- name: Prepare release notes
id: release_notes
run: |
# Add your custom release notes format here
echo "Custom notes" > release_notes.md
```
### Add New Platforms
To add macOS or Windows builds:
1. Add new `build-{platform}` job
2. Set appropriate `runs-on` runner
3. Add platform-specific dependencies
4. Update artifact upload
5. Include in `needs: [build-linux, build-android, build-{platform}]`
### Change Build Targets
Modify Tauri configuration or add targets:
```yaml
- name: Build for Linux
run: |
# Add target specification
bun run tauri build -- --target x86_64-unknown-linux-gnu
```
## Monitoring
### Check Status
1. Go to **Actions** tab in Gitea
2. View **Build & Release** workflow runs
3. Click specific run to see logs
### Notifications
Set up notifications for:
- Build failures
- Release creation
- Tag pushes
## Performance
### Build Times (Approximate)
- Test phase: 5-10 minutes
- Linux build: 10-15 minutes
- Android build: 15-20 minutes
- Total: 30-45 minutes
### Caching
Workflow caches:
- Rust dependencies (cargo)
- Bun node_modules
- Android SDK components
## Security
### Secrets
The workflow uses:
- `GITHUB_TOKEN` - Built-in, no setup needed
- No credentials needed for Gitea
### Verification
To verify build integrity:
1. Download artifacts
2. Verify signatures (if implemented)
3. Check file hashes
4. Test on target platform
## Best Practices
### Versioning
1. Follow semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
2. Tag releases in git
3. Update CHANGELOG.md before tagging
4. Include release notes in tag message
### Testing Before Release
```bash
# Local testing before release
bun run test # Frontend tests
bun run test:rust # Rust tests
bun run check # Type checking
bun run tauri build # Local build test
```
### Documentation
1. Update [CHANGELOG.md](../CHANGELOG.md) with changes
2. Update [README.md](../README.md) with new features
3. Document breaking changes
4. Add migration guide if needed
## Example Release Workflow
```bash
# 1. Update version in relevant files (package.json, Cargo.toml, etc.)
vim package.json
vim src-tauri/tauri.conf.json
# 2. Update CHANGELOG
vim CHANGELOG.md
# 3. Commit changes
git add .
git commit -m "Bump version to v1.0.0"
# 4. Create annotated tag
git tag -a v1.0.0 -m "Release version 1.0.0
Features:
- Feature 1
- Feature 2
Fixes:
- Fix 1
- Fix 2"
# 5. Push tag to trigger workflow
git push origin v1.0.0
# 6. Monitor workflow in Gitea Actions
# Wait for tests → Linux build → Android build → Release
# 7. Download artifacts and test
# Visit release page and verify downloads
```
## References
- [Tauri Documentation](https://tauri.app/)
- [Semantic Versioning](https://semver.org/)
- [GitHub Release Best Practices](https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases)
- [Android App Bundle](https://developer.android.com/guide/app-bundle)
- [AppImage Documentation](https://docs.appimage.org/)
---
**Last Updated:** 2026-02-13
-1327
View File
File diff suppressed because it is too large Load Diff
-288
View File
@@ -1,288 +0,0 @@
# Requirement Traceability CI/CD Pipeline
This document explains the automated requirement traceability validation system for JellyTau.
## Overview
The CI/CD pipeline automatically validates that code changes are properly traced to requirements. This ensures:
- ✅ Requirements are implemented with clear traceability
- ✅ No requirement coverage regressions
- ✅ Code changes are linked to specific requirements
- ✅ Quality metrics are tracked over time
## Gitea Actions Workflows
Two workflows are configured in `.gitea/workflows/`:
### 1. `traceability-check.yml` (Primary - Recommended)
Gitea-native workflow with:
- ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (50%)
- ✅ Modified file checking
- ✅ Artifact preservation
- ✅ Summary reports
**Runs on:** Every push and pull request
### 2. `traceability.yml` (Alternative)
GitHub-compatible workflow with additional features:
- Pull request comments with coverage stats
- GitHub-specific integrations
## What Gets Validated
### 1. Trace Extraction
```bash
bun run traces:json > traces-report.json
```
Extracts all TRACES comments from:
- TypeScript files (`src/**/*.ts`)
- Svelte components (`src/**/*.svelte`)
- Rust code (`src-tauri/src/**/*.rs`)
- Test files
### 2. Coverage Thresholds
The workflow checks:
- **Minimum overall coverage:** 50% (57+ requirements traced)
- **Requirements by type:**
- UR (User): 23+ of 39
- IR (Integration): 5+ of 24
- DR (Development): 28+ of 48
- JA (Jellyfin API): 0+ of 3
If coverage drops below threshold, the workflow **fails** and blocks merge.
### 3. Modified File Checking
On pull requests, the workflow:
1. Detects all changed TypeScript/Svelte/Rust files
2. Warns if new/modified files lack TRACES comments
3. Suggests the TRACES format for missing comments
## How to Add Traces to New Code
When you add new code or modify existing code, include TRACES comments:
### TypeScript/Svelte Example
```typescript
// TRACES: UR-005, UR-026 | DR-029
export function handlePlayback() {
// Implementation...
}
```
### Rust Example
```rust
/// TRACES: UR-005 | DR-001
pub fn player_state_changed(state: PlayerState) {
// Implementation...
}
```
### Test Example
```rust
// TRACES: UR-005 | DR-001 | UT-026, UT-027
#[cfg(test)]
mod tests {
// Tests...
}
```
## TRACES Format
```
TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
```
- `UR-###` - User Requirements (features users see)
- `IR-###` - Integration Requirements (API/platform integration)
- `DR-###` - Development Requirements (internal architecture)
- `JA-###` - Jellyfin API Requirements (Jellyfin API usage)
**Examples:**
- `// TRACES: UR-005` - Single requirement
- `// TRACES: UR-005, UR-026` - Multiple of same type
- `// TRACES: UR-005 | DR-029` - Multiple types
- `// TRACES: UR-005, UR-026 | DR-001, DR-029 | UT-001` - Complex
## Workflow Behavior
### On Push to Main Branch
1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 50%
3. ✅ Generates full traceability report
4. ✅ Saves report as artifact
### On Pull Request
1. ✅ Extracts all traces
2. ✅ Validates coverage >= 50%
3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format
6. ✅ Generates report artifact
### Failure Scenarios
The workflow **fails** (blocks merge) if:
- Coverage drops below 50%
- JSON extraction fails
- Invalid trace format
The workflow **warns** (but doesn't block) if:
- New files lack TRACES comments
- Coverage drops (but still above threshold)
## Viewing Reports
### In Gitea Actions UI
1. Go to **Actions** tab
2. Click the **Traceability Validation** workflow run
3. Download **traceability-reports** artifact
4. View:
- `traces-report.json` - Raw trace data
- `docs/TRACEABILITY.md` - Formatted report
### Locally
```bash
# Extract current traces
bun run traces:json | jq '.byType'
# Generate full report
bun run traces:markdown
cat docs/TRACEABILITY.md
```
## Coverage Goals
### Current Status
- Overall: 51% (56/114)
- UR: 59% (23/39)
- IR: 21% (5/24)
- DR: 58% (28/48)
- JA: 0% (0/3)
### Targets
- **Short term** (Sprint): Maintain ≥50% overall
- **Medium term** (Month): Reach 70% overall coverage
- **Long term** (Release): Reach 90% coverage with focus on:
- IR requirements (API clients)
- JA requirements (Jellyfin API endpoints)
- Remaining UR/DR requirements
## Improving Coverage
### For Missing User Requirements (UR)
1. Review [README.md](../README.md) for unimplemented features
2. Add TRACES to code that implements them
3. Focus on high-priority features (High/Medium priority)
### For Missing Integration Requirements (IR)
1. Add TRACES to Jellyfin API client methods
2. Add TRACES to platform-specific backends (Android/Linux)
3. Link to corresponding Jellyfin API endpoints
### For Missing Development Requirements (DR)
1. Add TRACES to UI components in `src/lib/components/`
2. Add TRACES to composables in `src/lib/composables/`
3. Add TRACES to player backend in `src-tauri/src/player/`
### For Jellyfin API Requirements (JA)
1. Add TRACES to Jellyfin API wrapper methods
2. Document which endpoints map to which requirements
3. Link to Jellyfin API documentation
## Example PR Checklist
When submitting a pull request:
- [ ] All new code has TRACES comments linking to requirements
- [ ] TRACES format is correct: `// TRACES: UR-001 | DR-002`
- [ ] Workflow passes (coverage ≥ 50%)
- [ ] No coverage regressions
- [ ] Artifact traceability report was generated
## Troubleshooting
### "Coverage below minimum threshold"
**Problem:** Workflow fails with coverage < 50%
**Solution:**
1. Run `bun run traces:json` locally
2. Check which requirements are traced
3. Add TRACES to untraced code sections
4. Re-run extraction to verify
### "New files without TRACES"
**Problem:** Workflow warns about new files lacking TRACES
**Solution:**
1. Add TRACES comments to all new code
2. Format: `// TRACES: UR-001 | DR-002`
3. Map code to specific requirements from README.md
4. Re-push
### "Invalid JSON format"
**Problem:** Trace extraction produces invalid JSON
**Solution:**
1. Check for malformed TRACES comments
2. Run locally: `bun run traces:json`
3. Look for parsing errors
4. Fix and retry
## Integration with Development
### Before Committing
```bash
# Check your traces
bun run traces:json | jq '.byType'
# Regenerate report
bun run traces:markdown
# Verify traces syntax
grep "TRACES:" src/**/*.ts src/**/*.rs
```
### In Your IDE
Add a file watcher to regenerate traces on save:
```json
{
"fileWatcher.watchPatterns": [
"src/**/*.ts",
"src/**/*.svelte",
"src-tauri/src/**/*.rs"
],
"fileWatcher.command": "bun run traces:markdown"
}
```
### Git Hooks
Add a pre-push hook to validate traces:
```bash
#!/bin/bash
# .git/hooks/pre-push
bun run traces:json > /dev/null
if [ $? -ne 0 ]; then
echo "❌ Invalid TRACES format"
exit 1
fi
```
## References
- [Extract Traces Script](../scripts/README.md#extract-tracests)
- [Requirements Specification](../README.md#requirements-specification)
- [Traceability Matrix](./TRACEABILITY.md)
- [Gitea Actions Documentation](https://docs.gitea.io/en-us/actions/)
## Support
For issues or questions:
1. Check this document
2. Review example traces in `src/lib/stores/`
3. Check existing TRACES comments for format
4. Review workflow logs in Gitea Actions
---
**Last Updated:** 2026-02-13
-24
View File
@@ -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
-376
View File
@@ -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/)
-105
View File
@@ -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();
-53
View File
@@ -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`);
}
-31
View File
@@ -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;
}
}
}
-55
View File
@@ -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();
-116
View File
@@ -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();
-39
View File
@@ -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);
});
});
-145
View File
@@ -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);
});
});
});
-39
View File
@@ -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);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.11.6",
"notes": "\nFound by an audit of the stack's most fragile seams rather than by hitting them,\nso most of these are faults that had not yet been reported — several could only\nbe reached on a bad day, and the worst of them only once.\n\n### 🐛 Fixes\n\n- **An interrupted update can no longer stop the app from ever opening again.**\n Changes to the local database were applied one statement at a time with no way\n to undo a half-finished one. If an update was interrupted partway — a full\n disk, the phone reclaiming memory, the app being killed mid-launch — the\n earlier statements stuck while nothing recorded that the change had happened.\n On the next launch it started again from the beginning, immediately hit the\n part that was already done, and gave up; and since the app treats a database\n it cannot prepare as fatal, it stopped opening at all, on every launch, with\n the only way out being to clear its data and lose downloads and sign-ins. Each\n change is now all-or-nothing, so an interrupted one leaves no trace and the\n next launch simply tries again. (UR-002 → DR-012)\n\n- **The app no longer vanishes without trace when the player hits trouble.**\n The parts of the Android player that report back into the app — position,\n state changes, errors, the end of a track — had no protection around them, and\n a failure inside one killed the whole app instantly: no error, no message, not\n even a crash report worth sending. One such failure was reachable in ordinary\n use, on the position report that fires four times a second: under memory\n pressure the app could fail to build the small worker it needs to send that\n report, and that alone was enough to take everything down. A dropped position\n report is now just a dropped position report. (UR-005 → DR-052)\n\n- **One internal failure no longer disables the whole app until it is restarted.**\n Every part of the app that reads or writes local data shares a single gate to\n it. If anything failed while holding that gate, the gate stayed jammed: from\n then on every library page, download, setting and sign-in returned an error for\n the rest of the session, and only quitting and reopening cleared it. The gate\n now recovers instead of jamming. (UR-002 → DR-012)\n\n- **Browsing offline no longer reports a network error over content already on\n the device.** A read of local data was given a tenth of a second to answer and\n otherwise abandoned and treated as \"nothing stored\". That is easily exceeded\n on phone storage whenever something else is writing — a sync catching up, a\n batch of artwork being saved — and offline, where there is no server to fall\n back to, the result was a network error shown over a library that was sitting\n on disk. Worse, the abandoned read kept running and kept the storage busy,\n making the next one slower still. A slow read is now waited for rather than\n thrown away, and a fast one still answers immediately as before. (UR-002 →\n DR-013)\n\n- **A download that arrived empty is no longer presented as ready to play.** If\n the server answered a download with nothing at all — an error page, a\n conversion that produced no output — the empty file was moved into place and\n the item was marked available offline. Opening it then hung: the app's own\n media server promised one byte of it and sent none, so the player waited\n forever with nothing on screen to say why. An empty download is now treated as\n the failure it is, keeping the partial file so it can resume, and a request for\n an empty file gets an honest refusal instead of a promise. (UR-019, UR-071 →\n DR-168, DR-137)\n\n- **Renaming your computer no longer signs you out.** On systems without a\n password manager, sign-in tokens are kept in a file whose key was rebuilt from\n the machine's name and the current username each time the app started. Rename\n the machine, or launch it from somewhere the username is not set, and the key\n came out different, the file could no longer be read, and th",
"pub_date": "2026-09-08T00:52:22Z",
"platforms": {
"linux-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaW9UT1dzTkV2SmVoNnM3aVNmeEs0UnA5Z2J3TzlsbjBTcWFUNlJMLzJFenl6UkE4STlCbTk5dzhkdUlzR00xb2pBdERzeFRHTnlpMy9mb2twbnp1VUFnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODIwNzM0CWZpbGU6SmVsbHlUYXVfMC4xMS42X2FtZDY0LkFwcEltYWdlCmNkSE9STFR2bUtvQXF3ckhxMFhqS09ETVRkL0VUZy81c054aFhod2JVZWcweEZWaVdGY0tKK2t6N3NFVmtwV1dnZ2VkNE1BZHBWbmJLRjRtSERqR0RnPT0K",
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.11.6/JellyTau_0.11.6_amd64.AppImage"
},
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVSczV4N1FMRVFQaXRROWhlaW5aYjlkU2kyOU5KdXFBSkRFbmtxQWRzVldBR0dHNHQvOXg0dG9uR0RDamY5N0VyVW1jWXZXYmN1WHBGaDVNSDJjSEVCb1dxMWJJdk1YYXdvPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4ODIyMzI3CWZpbGU6SmVsbHlUYXVfMC4xMS42X3g2NC1zZXR1cC5leGUKYVVITU1UNzVUTVZLbUZkVVJFbmVtUVFtYTVaeFZDbUlpclZBRW5kV3BwL2ZLZEpseFphTHhUc2Q3bU1Nd1VmdGVxbURlbWtHajcxV3Z0SVZ4cUM2QlE9PQo=",
"url": "https://gitea.tourolle.paris/dtourolle/jellytau/releases/download/v0.11.6/JellyTau_0.11.6_x64-setup.exe"
}
}
}
-10227
View File
File diff suppressed because it is too large Load Diff
-62
View File
@@ -1,62 +0,0 @@
{
"name": "jellytau",
"version": "0.1.0",
"description": "",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "wdio run ./wdio.conf.ts",
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
"test:all": "./scripts/test-all.sh",
"test:rust": "./scripts/test-rust.sh",
"android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release",
"android:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh",
"android:logs": "./scripts/logcat.sh",
"clean": "./scripts/clean.sh",
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
"traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.16",
"@wdio/cli": "^9.5.0",
"@wdio/local-runner": "^9.5.0",
"@wdio/mocha-framework": "^9.5.0",
"@wdio/spec-reporter": "^9.5.0",
"happy-dom": "^20.0.11",
"jsdom": "^27.4.0",
"svelte": "^5.47.1",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.0.16",
"webdriverio": "^9.5.0"
}
}
-118
View File
@@ -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](../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
```
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
# Build and deploy Android APK in one command
set -e
BUILD_TYPE="${1:-debug}"
echo "🚀 Build and Deploy Android APK"
echo ""
# Build APK
./scripts/build-android.sh "$BUILD_TYPE"
echo ""
# Deploy APK
./scripts/deploy-android.sh "$BUILD_TYPE"
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
# Build Android APK
set -e
# Source Rust environment
source "$HOME/.cargo/env.fish" 2>/dev/null || source "$HOME/.cargo/env" 2>/dev/null || true
# Set Android environment variables
export ANDROID_HOME="$HOME/Android/Sdk"
export NDK_HOME="$ANDROID_HOME/ndk/$(ls "$ANDROID_HOME/ndk" | head -1)"
echo "🤖 Building Android APK..."
echo "Android SDK: $ANDROID_HOME"
echo "NDK: $NDK_HOME"
echo ""
# Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}"
# Step 1: Sync Android source files
echo "🔄 Syncing Android sources..."
./scripts/sync-android-sources.sh
# Step 2: Build the frontend first to avoid dev server issues
echo "🎨 Building frontend..."
bun run build
# Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then
echo "📦 Building release APK..."
bun run tauri android build --apk true
else
echo "📦 Building debug APK..."
bun run tauri android build --apk true --debug
fi
echo ""
echo "✅ APK build complete!"
echo "📱 APK location: src-tauri/gen/android/app/build/outputs/apk/"
-44
View File
@@ -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}"
-55
View File
@@ -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!"
-84
View File
@@ -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
-40
View File
@@ -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 ""
-40
View File
@@ -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!"
-37
View File
@@ -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"
-281
View File
@@ -1,281 +0,0 @@
#!/usr/bin/env bun
/**
* Extract TRACES from source code and generate requirement mapping
*
* Usage:
* bun run scripts/extract-traces.ts
* bun run scripts/extract-traces.ts --format json
* bun run scripts/extract-traces.ts --format markdown > docs/TRACEABILITY.md
*/
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
interface TraceEntry {
file: string;
line: number;
context: string;
requirements: string[];
}
interface RequirementMapping {
[reqId: string]: TraceEntry[];
}
interface TracesData {
timestamp: string;
totalFiles: number;
totalTraces: number;
requirements: RequirementMapping;
byType: {
UR: string[];
IR: string[];
DR: string[];
JA: string[];
};
}
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
function extractRequirementIds(tracesString: string): string[] {
const matches = [...tracesString.matchAll(REQ_ID_PATTERN)];
return matches.map((m) => `${m[1]}-${m[2]}`);
}
function getAllSourceFiles(): string[] {
const baseDir = "/home/dtourolle/Development/JellyTau";
const patterns = ["src", "src-tauri/src"];
const files: string[] = [];
function walkDir(dir: string) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(baseDir, fullPath);
// Skip node_modules, target, build
if (
relativePath.includes("node_modules") ||
relativePath.includes("target") ||
relativePath.includes("build") ||
relativePath.includes(".git")
) {
continue;
}
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (
entry.name.endsWith(".ts") ||
entry.name.endsWith(".svelte") ||
entry.name.endsWith(".rs")
) {
files.push(fullPath);
}
}
} catch (error) {
// Skip directories we can't read
}
}
for (const pattern of patterns) {
const dir = path.join(baseDir, pattern);
if (fs.existsSync(dir)) {
walkDir(dir);
}
}
return files;
}
function extractTraces(): TracesData {
const requirementMap: RequirementMapping = {};
const byType: Record<string, Set<string>> = {
UR: new Set(),
IR: new Set(),
DR: new Set(),
JA: new Set(),
};
let totalTraces = 0;
const baseDir = "/home/dtourolle/Development/JellyTau";
const files = getAllSourceFiles();
for (const fullPath of files) {
try {
const content = fs.readFileSync(fullPath, "utf-8");
const lines = content.split("\n");
const relativePath = path.relative(baseDir, fullPath);
let match;
TRACES_PATTERN.lastIndex = 0;
while ((match = TRACES_PATTERN.exec(content)) !== null) {
const tracesStr = match[1];
const reqIds = extractRequirementIds(tracesStr);
if (reqIds.length === 0) continue;
// Find line number
const beforeMatch = content.substring(0, match.index);
const lineNum = beforeMatch.split("\n").length - 1;
// Get context (function/class name if available)
let context = "Unknown";
for (let i = lineNum; i >= Math.max(0, lineNum - 10); i--) {
const line = lines[i];
if (
line.includes("function ") ||
line.includes("export const ") ||
line.includes("pub fn ") ||
line.includes("pub enum ") ||
line.includes("pub struct ") ||
line.includes("impl ") ||
line.includes("async function ") ||
line.includes("class ") ||
line.includes("export type ")
) {
context = line
.trim()
.replace(/^\s*\/\/\s*/, "")
.replace(/^\s*\/\*\*\s*/, "");
break;
}
}
const entry: TraceEntry = {
file: relativePath,
line: lineNum + 1,
context,
requirements: reqIds,
};
for (const reqId of reqIds) {
if (!requirementMap[reqId]) {
requirementMap[reqId] = [];
}
requirementMap[reqId].push(entry);
// Track by type
const type = reqId.substring(0, 2);
if (byType[type]) {
byType[type].add(reqId);
}
}
totalTraces++;
}
} catch (error) {
// Skip files we can't read
}
}
return {
timestamp: new Date().toISOString(),
totalFiles: files.length,
totalTraces,
requirements: requirementMap,
byType: {
UR: Array.from(byType["UR"]).sort(),
IR: Array.from(byType["IR"]).sort(),
DR: Array.from(byType["DR"]).sort(),
JA: Array.from(byType["JA"]).sort(),
},
};
}
function generateMarkdown(data: TracesData): string {
let md = `# Code Traceability Matrix
**Generated:** ${new Date(data.timestamp).toLocaleString()}
## Summary
- **Total Files Scanned:** ${data.totalFiles}
- **Total TRACES Found:** ${data.totalTraces}
- **Requirements Covered:**
- User Requirements (UR): ${data.byType.UR.length}
- Integration Requirements (IR): ${data.byType.IR.length}
- Development Requirements (DR): ${data.byType.DR.length}
- Jellyfin API Requirements (JA): ${data.byType.JA.length}
## Requirements by Type
### User Requirements (UR)
\`\`\`
${data.byType.UR.join(", ")}
\`\`\`
### Integration Requirements (IR)
\`\`\`
${data.byType.IR.join(", ")}
\`\`\`
### Development Requirements (DR)
\`\`\`
${data.byType.DR.join(", ")}
\`\`\`
### Jellyfin API Requirements (JA)
\`\`\`
${data.byType.JA.join(", ")}
\`\`\`
## Detailed Mapping
`;
// Sort requirements by ID
const sortedReqs = Object.keys(data.requirements).sort((a, b) => {
const typeA = a.substring(0, 2);
const typeB = b.substring(0, 2);
const typeOrder = { UR: 0, IR: 1, DR: 2, JA: 3 };
if (typeOrder[typeA] !== typeOrder[typeB]) {
return (typeOrder[typeA] || 4) - (typeOrder[typeB] || 4);
}
return a.localeCompare(b);
});
for (const reqId of sortedReqs) {
const entries = data.requirements[reqId];
md += `### ${reqId}\n\n`;
md += `**Locations:** ${entries.length} file(s)\n\n`;
for (const entry of entries) {
md += `- **File:** [\`${entry.file}\`](${entry.file}#L${entry.line})\n`;
md += ` - **Line:** ${entry.line}\n`;
const contextPreview = entry.context.substring(0, 70);
md += ` - **Context:** \`${contextPreview}${entry.context.length > 70 ? "..." : ""}\`\n`;
}
md += "\n";
}
return md;
}
function generateJson(data: TracesData): string {
return JSON.stringify(data, null, 2);
}
// Main
const args = Bun.argv.slice(2);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
if (format === "json") {
console.log(generateJson(data));
} else {
console.log(generateMarkdown(data));
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
-56
View File
@@ -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 ""
-38
View File
@@ -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
-13
View File
@@ -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"
-34
View File
@@ -1,34 +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
echo "✓ Android sources synced successfully"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
# Run all tests (frontend and backend)
set -e
echo "🧪 Running all tests..."
echo ""
echo "📦 Running frontend tests..."
bun test
echo ""
echo "🦀 Running Rust tests..."
cd src-tauri
cargo test
cd ..
echo ""
echo "✅ All tests passed!"
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
# Run frontend tests only
set -e
echo "📦 Running frontend tests..."
bun test "$@"
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
# Run Rust tests only
set -e
echo "🦀 Running Rust tests..."
cd src-tauri
cargo test "$@"
cd ..
-38
View File
@@ -1,38 +0,0 @@
[target.aarch64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.armv7-linux-androideabi]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.i686-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[target.x86_64-linux-android]
linker = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
ar = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
[env]
# Point to the NDK for the cc crate and other build scripts
ANDROID_NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
NDK_HOME = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006"
# Set CC/CXX for each Android target (cc crate looks for these)
CC_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
CXX_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang++"
AR_aarch64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang"
CXX_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi34-clang++"
AR_armv7-linux-androideabi = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang"
CXX_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/i686-linux-android34-clang++"
AR_i686-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
CC_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
CXX_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang++"
AR_x86_64-linux-android = "/home/dtourolle/Android/Sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
-24
View File
@@ -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
-5987
View File
File diff suppressed because it is too large Load Diff
-63
View File
@@ -1,63 +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"]
[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"] }
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"
# 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"
-51
View File
@@ -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,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)
}
-39
View File
@@ -1,39 +0,0 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Enable hardware acceleration for video playback performance -->
<application android:hardwareAccelerated="true" />
</manifest>
@@ -1,226 +0,0 @@
package com.dtourolle.jellytau
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.view.View
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
private val maxConfigAttempts = 10
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
}, 500)
}
override fun onResume() {
super.onResume()
configureWebViewForMedia()
}
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
if (configAttempts < maxConfigAttempts) {
configAttempts++
handler.postDelayed({
configureWebViewForMedia()
}, 200)
} else {
android.util.Log.e("MainActivity", "Failed to find WebView after $maxConfigAttempts attempts")
}
return
}
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
}
@JavascriptInterface
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
super.onShowCustomView(view, callback)
android.util.Log.d("MainActivity", "Video entered fullscreen")
}
override fun onHideCustomView() {
super.onHideCustomView()
android.util.Log.d("MainActivity", "Video exited fullscreen")
}
}
android.util.Log.d("MainActivity", "WebChromeClient configured")
webView.settings.apply {
// CRITICAL: Enable media playback without user gesture requirement
mediaPlaybackRequiresUserGesture = false
android.util.Log.d("MainActivity", "Set mediaPlaybackRequiresUserGesture = false")
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
setRenderPriority(WebSettings.RenderPriority.HIGH)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
}
// Execute JavaScript to ensure any video elements are unmuted and request audio focus
webView.post {
webView.evaluateJavascript("""
(function() {
console.log('[Android] Ensuring video elements are unmuted');
const videos = document.getElementsByTagName('video');
for (let video of videos) {
video.muted = false;
video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus
video.addEventListener('play', function() {
console.log('[Android] Video play event - requesting audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.requestAudioFocus();
}
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
});
video.addEventListener('pause', function() {
console.log('[Android] Video pause event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
});
}
// Monitor for new video elements
const observer = new MutationObserver(() => {
const videos = document.getElementsByTagName('video');
for (let video of videos) {
if (video.muted) {
video.muted = false;
video.volume = 1.0;
console.log('[Android] New video found and unmuted');
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
console.log('[Android] Video unmute observer installed');
})();
""".trimIndent(), null)
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) {
android.util.Log.d("MainActivity", "Found WebView!")
return view
}
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
val child = view.getChildAt(i)
val webView = findWebView(child)
if (webView != null) {
return webView
}
}
}
return null
}
private fun requestAudioFocus() {
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
android.util.Log.d("MainActivity", "Audio focus request result: $result")
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
{ focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
}
}
private fun abandonAudioFocus() {
android.util.Log.d("MainActivity", "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus { }
}
}
}
@@ -1,130 +0,0 @@
package com.dtourolle.jellytau.player
import android.media.MediaCodecList
import android.util.Log
/**
* Detects hardware codec capabilities using MediaCodecList.
*
* This class queries the device's media codec capabilities and reports
* them to the Rust backend via JNI for accurate DeviceProfile generation.
*/
object CodecDetector {
private const val TAG = "CodecDetector"
/**
* Data class to hold detected codec capabilities.
*/
data class CodecCapabilities(
val videoCodecs: List<String>,
val audioCodecs: List<String>
)
/**
* Detect all hardware decoders available on this device.
*
* Uses Android's MediaCodecList API to query supported MIME types
* and maps them to Jellyfin codec names.
*
* @return CodecCapabilities containing lists of supported video and audio codecs
*/
fun detectHardwareCodecs(): CodecCapabilities {
val videoCodecs = mutableSetOf<String>()
val audioCodecs = mutableSetOf<String>()
try {
// Get all codec infos (including both hardware and software codecs)
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
for (codecInfo in codecList.codecInfos) {
// Only interested in decoders (not encoders)
if (codecInfo.isEncoder) continue
// Check if it's a hardware codec
val isHardware = !codecInfo.isSoftwareOnly
for (type in codecInfo.supportedTypes) {
when {
type.startsWith("video/") -> {
val codec = mapMimeTypeToCodecName(type, isVideo = true)
if (codec != null) {
videoCodecs.add(codec)
Log.d(TAG, "Video codec: $codec (MIME: $type, Hardware: $isHardware)")
}
}
type.startsWith("audio/") -> {
val codec = mapMimeTypeToCodecName(type, isVideo = false)
if (codec != null) {
audioCodecs.add(codec)
Log.d(TAG, "Audio codec: $codec (MIME: $type, Hardware: $isHardware)")
}
}
}
}
}
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
} catch (e: Exception) {
Log.e(TAG, "Error detecting codecs", e)
}
return CodecCapabilities(
videoCodecs = videoCodecs.sorted(),
audioCodecs = audioCodecs.sorted()
)
}
/**
* Map Android MIME types to Jellyfin codec names.
*
* Based on Jellyfin's codec naming conventions and Android's
* supported MIME type constants.
*
* @param mimeType Android MIME type (e.g., "video/avc", "audio/mp4a-latm")
* @param isVideo Whether this is a video codec
* @return Jellyfin codec name or null if unknown
*/
private fun mapMimeTypeToCodecName(mimeType: String, isVideo: Boolean): String? {
return when (mimeType) {
// Video codecs
"video/avc" -> "h264"
"video/hevc" -> "hevc"
"video/x-vnd.on2.vp8" -> "vp8"
"video/x-vnd.on2.vp9" -> "vp9"
"video/av01" -> "av1"
"video/mp4v-es" -> "mpeg4"
"video/3gpp" -> "h263"
"video/mpeg2" -> "mpeg2video"
"video/divx" -> "divx"
"video/xvid" -> "xvid"
"video/x-ms-wmv" -> "wmv"
"video/vc1" -> "vc1"
// Audio codecs
"audio/mp4a-latm" -> "aac"
"audio/mpeg" -> "mp3"
"audio/mpeg-L1" -> "mp1"
"audio/mpeg-L2" -> "mp2"
"audio/opus" -> "opus"
"audio/vorbis" -> "vorbis"
"audio/flac" -> "flac"
"audio/alac" -> "alac"
"audio/ac3" -> "ac3"
"audio/eac3" -> "eac3"
"audio/eac3-joc" -> "eac3"
"audio/dts" -> "dts"
"audio/vnd.dts.hd" -> "dts"
"audio/x-ms-wma" -> "wma"
"audio/amr-nb" -> "amrnb"
"audio/amr-wb" -> "amrwb"
"audio/3gpp" -> "amrnb"
"audio/raw" -> "pcm"
else -> {
Log.d(TAG, "Unknown MIME type: $mimeType")
null
}
}
}
}
@@ -1,527 +0,0 @@
package com.dtourolle.jellytau.player
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media.VolumeProviderCompat
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
/**
* MediaSessionService for lockscreen controls and media notifications.
*
* This service creates a MediaSession that integrates with the system's
* media controls (lockscreen, notification shade, Bluetooth devices).
*
* Media commands are routed back to Rust via JNI to ensure proper
* queue management for next/previous track operations.
*/
@OptIn(UnstableApi::class)
class JellyTauPlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private var mediaSessionCompat: MediaSessionCompat? = null
private var wrappedPlayer: androidx.media3.common.ForwardingPlayer? = null
private var volumeProvider: VolumeProviderCompat? = null
private var isRemoteVolumeEnabled = false
private var remoteVolumeLevel = 50 // 0-100
companion object {
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_CHANNEL_ID = "playback_channel"
private const val NOTIFICATION_CHANNEL_NAME = "Playback"
@Volatile
private var instance: JellyTauPlaybackService? = null
/**
* Get the service instance if running.
*/
@JvmStatic
fun getInstance(): JellyTauPlaybackService? = instance
init {
// Ensure native library is loaded for JNI callbacks
System.loadLibrary("jellytau_lib")
}
}
override fun onCreate() {
super.onCreate()
instance = this
// Create notification channel for Android O+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Media playback controls"
setShowBadge(false)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
// Check if JellyTauPlayer is initialized
if (!JellyTauPlayer.isInitialized()) {
android.util.Log.w("JellyTauPlaybackService", "JellyTauPlayer not initialized, initializing now")
// Initialize the player with application context
JellyTauPlayer.initialize(applicationContext)
}
// Get the existing JellyTauPlayer instance with its ExoPlayer
val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play")
}
override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause")
}
override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next")
}
override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous")
}
override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop")
}
}
// Create MediaSession with the wrapped player and callback for command handling
mediaSession = MediaSession.Builder(this, wrappedPlayer!!)
.setCallback(object : MediaSession.Callback {
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<androidx.media3.common.MediaItem>,
startIndex: Int,
startPositionMs: Long
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
return Futures.immediateFuture(
MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs)
)
}
})
.build()
// Create MediaSessionCompat for volume control and lock screen button handling
// We need this alongside Media3's MediaSession because MediaSessionCompat provides
// VolumeProviderCompat support for remote volume control (routing hardware button presses)
mediaSessionCompat = MediaSessionCompat(this, "JellyTauMediaSession").apply {
setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
isActive = true
// Set callback to handle lock screen button presses
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play()
}
override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause()
}
override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext()
}
override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious()
}
override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop()
}
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position)
}
})
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Start as foreground service immediately to avoid crash
// Media3 will replace this with its own notification
val notification = createBasicNotification()
startForeground(NOTIFICATION_ID, notification)
return super.onStartCommand(intent, flags, startId)
}
private fun createBasicNotification(): Notification {
// Create a media-style notification with lockscreen controls
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("JellyTau")
.setContentText("Playing")
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
)
.addAction(
android.R.drawable.ic_media_previous,
"Previous",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
.addAction(
android.R.drawable.ic_media_pause,
"Pause",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_PAUSE
)
)
.addAction(
android.R.drawable.ic_media_next,
"Next",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
.build()
}
/**
* Update the MediaSession metadata and playback state.
* This updates both the MediaSession and the notification.
*/
fun updateMediaMetadata(
title: String,
artist: String,
album: String?,
duration: Long,
position: Long,
isPlaying: Boolean
) {
val session = mediaSessionCompat ?: return
// Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putLong(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DURATION, duration)
album?.let {
metadataBuilder.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_ALBUM, it)
}
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
PlaybackStateCompat.ACTION_SEEK_TO
)
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position,
1.0f
)
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update the notification with current media metadata and playback state.
* This should be called whenever metadata or playback state changes.
*/
private fun updateNotification(title: String, artist: String, isPlaying: Boolean) {
val intent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setContentText(artist)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentIntent(pendingIntent)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSessionCompat?.sessionToken)
.setShowActionsInCompactView(0, 1, 2) // Show all 3 buttons in compact view
)
.addAction(
android.R.drawable.ic_media_previous,
"Previous",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
.addAction(
if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (isPlaying) "Pause" else "Play",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
if (isPlaying) PlaybackStateCompat.ACTION_PAUSE else PlaybackStateCompat.ACTION_PLAY
)
)
.addAction(
android.R.drawable.ic_media_next,
"Next",
androidx.media.session.MediaButtonReceiver.buildMediaButtonPendingIntent(
this,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(isPlaying)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // Show on lockscreen
.build()
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(NOTIFICATION_ID, notification)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
/**
* Enable remote volume control for remote playback (e.g., casting to Jellyfin session).
* Volume button presses will be sent to Rust for forwarding to the remote session.
*
* Uses MediaSessionCompat with VolumeProviderCompat to intercept hardware volume buttons.
*
* @param initialVolume Initial volume level (0-100)
*/
fun enableRemoteVolume(initialVolume: Int) {
android.util.Log.d("JellyTauPlaybackService", "Enabling remote volume control (volume=$initialVolume)")
isRemoteVolumeEnabled = true
remoteVolumeLevel = initialVolume.coerceIn(0, 100)
val session = mediaSessionCompat ?: run {
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
return
}
// Create a VolumeProvider for remote volume control
volumeProvider = object : VolumeProviderCompat(
VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, // Control type: absolute volume
100, // Max volume (0-100)
remoteVolumeLevel // Initial volume
) {
override fun onSetVolumeTo(volume: Int) {
if (!isRemoteVolumeEnabled) return
remoteVolumeLevel = volume.coerceIn(0, 100)
android.util.Log.d("JellyTauPlaybackService", "Remote volume set to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("SetVolume", remoteVolumeLevel)
}
override fun onAdjustVolume(direction: Int) {
if (!isRemoteVolumeEnabled) return
when (direction) {
android.media.AudioManager.ADJUST_RAISE -> {
remoteVolumeLevel = (remoteVolumeLevel + 2).coerceAtMost(100)
android.util.Log.d("JellyTauPlaybackService", "Remote volume up to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("VolumeUp", remoteVolumeLevel)
// Update the current volume so slider reflects the change
currentVolume = remoteVolumeLevel
}
android.media.AudioManager.ADJUST_LOWER -> {
remoteVolumeLevel = (remoteVolumeLevel - 2).coerceAtLeast(0)
android.util.Log.d("JellyTauPlaybackService", "Remote volume down to $remoteVolumeLevel")
nativeOnRemoteVolumeChange("VolumeDown", remoteVolumeLevel)
// Update the current volume so slider reflects the change
currentVolume = remoteVolumeLevel
}
}
}
}
// Set the volume provider on the media session to route hardware volume buttons
session.setPlaybackToRemote(volumeProvider!!)
// Set playback state to make Android show the volume UI
// This tells Android that this session is actively controlling media playback
val playbackState = PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_PLAYING,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1.0f
)
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
.build()
session.setPlaybackState(playbackState)
android.util.Log.d("JellyTauPlaybackService", "Remote volume control enabled")
}
/**
* Disable remote volume control and return to local volume control.
* Volume buttons will control system media volume (ExoPlayer volume).
*/
fun disableRemoteVolume() {
android.util.Log.d("JellyTauPlaybackService", "Disabling remote volume control")
isRemoteVolumeEnabled = false
val session = mediaSessionCompat ?: run {
android.util.Log.w("JellyTauPlaybackService", "MediaSessionCompat not initialized")
return
}
// Switch back to local audio stream (device volume)
session.setPlaybackToLocal(android.media.AudioManager.STREAM_MUSIC)
// Clear the playback state
val idleState = PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_NONE,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
0.0f
)
.build()
session.setPlaybackState(idleState)
// Clear the volume provider
volumeProvider = null
// Reset volume level to default
remoteVolumeLevel = 50
android.util.Log.d("JellyTauPlaybackService", "Remote volume control disabled")
}
/**
* Update the remote volume level.
* Call this when volume changes on the remote session to sync the local state.
*
* @param volume Volume level (0-100)
*/
fun updateRemoteVolume(volume: Int) {
remoteVolumeLevel = volume.coerceIn(0, 100)
// Update the volume provider's current volume so the UI slider reflects the change
volumeProvider?.currentVolume = remoteVolumeLevel
android.util.Log.d("JellyTauPlaybackService", "Remote volume updated to $remoteVolumeLevel")
}
override fun onDestroy() {
mediaSession?.run {
release()
}
mediaSession = null
mediaSessionCompat?.run {
isActive = false
release()
}
mediaSessionCompat = null
volumeProvider = null
instance = null
super.onDestroy()
}
override fun onTaskRemoved(rootIntent: Intent?) {
// Stop the service when the app is swiped away, unless audio is playing
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
/**
* JNI callback to Rust for media commands.
* Commands: "play", "pause", "next", "previous", "stop", "seek:123.45"
*/
private external fun nativeOnMediaCommand(command: String)
/**
* JNI callback to Rust for remote volume changes.
* Commands: "SetVolume", "VolumeUp", "VolumeDown"
* @param command The volume command
* @param volume The volume level (0-100)
*/
private external fun nativeOnRemoteVolumeChange(command: String, volume: Int)
}
File diff suppressed because it is too large Load Diff
@@ -1,167 +0,0 @@
package com.dtourolle.jellytau.security
import android.content.Context
import android.content.SharedPreferences
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Secure storage for credentials using Android Keystore.
* Provides encrypted storage for sensitive data like API tokens.
*/
class SecureStorage private constructor(context: Context) {
companion object {
private const val TAG = "SecureStorage"
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val KEY_ALIAS = "jellytau_credentials_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val PREFS_NAME = "jellytau_secure_prefs"
@Volatile
private var instance: SecureStorage? = null
@JvmStatic
fun initialize(context: Context) {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = SecureStorage(context.applicationContext)
}
}
}
}
@JvmStatic
fun getInstance(): SecureStorage {
return instance ?: throw IllegalStateException("SecureStorage not initialized")
}
}
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
load(null)
}
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
init {
// Ensure encryption key exists
if (!keyStore.containsAlias(KEY_ALIAS)) {
generateKey()
}
}
private fun generateKey() {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
private fun getSecretKey(): SecretKey {
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
fun saveCredential(key: String, value: String) {
try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
// Store IV + encrypted data as base64
val combined = iv + encrypted
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
prefs.edit().putString(key, encoded).apply()
Log.d(TAG, "Saved credential: $key")
} catch (e: Exception) {
Log.e(TAG, "Failed to save credential: $key", e)
throw e
}
}
fun getCredential(key: String): String? {
try {
val encoded = prefs.getString(key, null) ?: return null
val combined = Base64.decode(encoded, Base64.DEFAULT)
// Extract IV (first 12 bytes for GCM)
val iv = combined.copyOfRange(0, 12)
val encrypted = combined.copyOfRange(12, combined.size)
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), spec)
val decrypted = cipher.doFinal(encrypted)
return String(decrypted, Charsets.UTF_8)
} catch (e: Exception) {
Log.e(TAG, "Failed to get credential: $key", e)
return null
}
}
fun deleteCredential(key: String) {
prefs.edit().remove(key).apply()
Log.d(TAG, "Deleted credential: $key")
}
// JNI-compatible methods (called from Rust)
/**
* Save a token (JNI-compatible version).
* @return true if successful, false otherwise
*/
@JvmOverloads
fun saveToken(key: String, value: String): Boolean {
return try {
saveCredential(key, value)
true
} catch (e: Exception) {
Log.e(TAG, "saveToken failed for key: $key", e)
false
}
}
/**
* Get a token (JNI-compatible version).
* @return token string or null if not found
*/
fun getToken(key: String): String? {
return getCredential(key)
}
/**
* Delete a token (JNI-compatible version).
* @return true if successful, false otherwise
*/
fun deleteToken(key: String): Boolean {
return try {
deleteCredential(key)
true
} catch (e: Exception) {
Log.e(TAG, "deleteToken failed for key: $key", e)
false
}
}
}
@@ -1,13 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme -->
<style name="Theme.jellytau" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Status bar color -->
<item name="android:statusBarColor">@android:color/transparent</item>
<!-- Make status bar icons dark or light based on background -->
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
<!-- Don't draw behind status bar -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<!-- Ensure content doesn't extend into system bars -->
<item name="android:fitsSystemWindows">true</item>
</style>
</resources>
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:path:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-409
View File
@@ -1,409 +0,0 @@
pub mod session_verifier;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::jellyfin::http_client::HttpClient;
use crate::connectivity::ConnectivityMonitor;
pub use session_verifier::SessionVerifier;
/// Server information returned from Jellyfin
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub name: String,
pub version: String,
pub id: String,
/// Normalized server URL with protocol and no trailing slash
pub normalized_url: String,
}
/// User information
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
pub name: String,
pub server_id: String,
pub primary_image_tag: Option<String>,
}
/// Authentication result
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthResult {
pub user: User,
pub access_token: String,
pub server_id: String,
}
/// Active session for restoration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub user_id: String,
pub username: String,
pub server_id: String,
pub server_url: String,
pub server_name: String,
pub access_token: String,
pub verified: bool,
pub needs_reauth: bool,
}
// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PublicSystemInfo {
server_name: String,
version: String,
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AuthenticateByNameResponse {
user: JellyfinUser,
access_token: String,
server_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUser {
id: String,
name: String,
server_id: String,
primary_image_tag: Option<String>,
}
/// Authentication manager
pub struct AuthManager {
http_client: Arc<HttpClient>,
current_session: Arc<RwLock<Option<Session>>>,
connectivity_monitor: Option<Arc<tokio::sync::Mutex<ConnectivityMonitor>>>,
}
impl AuthManager {
/// Create a new auth manager
pub fn new(http_client: HttpClient) -> Self {
Self {
http_client: Arc::new(http_client),
current_session: Arc::new(RwLock::new(None)),
connectivity_monitor: None,
}
}
/// Set the connectivity monitor (for marking server reachability)
pub fn set_connectivity_monitor(&mut self, monitor: Arc<tokio::sync::Mutex<ConnectivityMonitor>>) {
self.connectivity_monitor = Some(monitor);
}
/// Normalize and validate server URL
pub fn normalize_url(url: &str) -> String {
let mut normalized = url.trim().to_string();
// Add https:// if no protocol specified
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
normalized = format!("https://{}", normalized);
}
// Remove trailing slash
if normalized.ends_with('/') {
normalized.pop();
}
normalized
}
/// Connect to server and get server info
pub async fn connect_to_server(&self, server_url: &str) -> Result<ServerInfo, String> {
let normalized_url = Self::normalize_url(server_url);
let endpoint = format!("{}/System/Info/Public", normalized_url);
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
Ok(info) => {
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(ServerInfo {
name: info.server_name,
version: info.version,
id: info.id,
normalized_url,
})
}
Err(e) => {
log::error!("[AuthManager] Failed to connect to server: {}", e);
// Mark server as unreachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(e.clone())).await;
}
Err(e)
}
}
}
/// Authenticate by username and password
pub async fn login(
&self,
server_url: &str,
username: &str,
password: &str,
device_id: &str,
) -> Result<AuthResult, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/AuthenticateByName", url);
log::info!("[AuthManager] Authenticating user: {}", username);
// Build auth header for login request
let auth_header = HttpClient::build_auth_header(None, device_id);
// Build request manually for custom headers
let request = self.http_client.client.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password,
}))
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| format!("Login request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Login failed: HTTP {}: {}", status, error_text));
}
let auth_response: AuthenticateByNameResponse = response.json().await
.map_err(|e| format!("Failed to parse login response: {}", e))?;
log::info!("[AuthManager] Login successful for user: {} ({})", auth_response.user.name, auth_response.user.id);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
let user = User {
id: auth_response.user.id,
name: auth_response.user.name,
server_id: auth_response.user.server_id,
primary_image_tag: auth_response.user.primary_image_tag,
};
Ok(AuthResult {
user,
access_token: auth_response.access_token,
server_id: auth_response.server_id,
})
}
/// Verify current session by fetching user info
pub async fn verify_session(
&self,
server_url: &str,
user_id: &str,
access_token: &str,
device_id: &str,
) -> Result<User, String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Users/{}", url, user_id);
log::info!("[AuthManager] Verifying session for user: {}", user_id);
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request manually for custom headers
let request = self.http_client.client.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Use retry logic
let response = self.http_client.request_with_retry(request).await
.map_err(|e| {
log::warn!("[AuthManager] Session verification failed: {}", e);
format!("Session verification failed: {}", e)
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
// Mark server as unreachable for auth errors
if status.as_u16() == 401 || status.as_u16() == 403 {
log::warn!("[AuthManager] Session invalid: HTTP {}", status);
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_unreachable(Some(format!("Authentication failed: {}", status))).await;
}
}
return Err(format!("HTTP {}: {}", status, error_text));
}
let user_response: JellyfinUser = response.json().await
.map_err(|e| format!("Failed to parse user response: {}", e))?;
log::info!("[AuthManager] Session verified successfully for: {}", user_response.name);
// Mark server as reachable
if let Some(monitor) = &self.connectivity_monitor {
let monitor = monitor.lock().await;
monitor.mark_reachable().await;
}
Ok(User {
id: user_response.id,
name: user_response.name,
server_id: user_response.server_id,
primary_image_tag: user_response.primary_image_tag,
})
}
/// Logout (call Jellyfin logout endpoint)
pub async fn logout(
&self,
server_url: &str,
access_token: &str,
device_id: &str,
) -> Result<(), String> {
let url = Self::normalize_url(server_url);
let endpoint = format!("{}/Sessions/Logout", url);
log::info!("[AuthManager] Logging out");
// Build auth header
let auth_header = HttpClient::build_auth_header(Some(access_token), device_id);
// Build request
let request = self.http_client.client.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
// Don't retry logout - if it fails, we'll still clear local state
match self.http_client.client.execute(request).await {
Ok(response) => {
if response.status().is_success() {
log::info!("[AuthManager] Logout successful");
} else {
log::warn!("[AuthManager] Logout request failed: {}", response.status());
}
}
Err(e) => {
log::warn!("[AuthManager] Logout request failed: {}", e);
}
}
Ok(())
}
/// Get current session
pub async fn get_session(&self) -> Option<Session> {
self.current_session.read().await.clone()
}
/// Set current session
pub async fn set_session(&self, session: Option<Session>) {
*self.current_session.write().await = session;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test URL normalization - adds https:// when missing
///
/// Ensures that URLs without protocol are normalized to https://
/// This prevents "builder error" when constructing HTTP requests.
#[test]
fn test_normalize_url_adds_https() {
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("192.168.1.100:8096"),
"https://192.168.1.100:8096"
);
}
/// Test URL normalization - preserves existing protocol
#[test]
fn test_normalize_url_preserves_protocol() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("http://localhost:8096"),
"http://localhost:8096"
);
}
/// Test URL normalization - removes trailing slash
#[test]
fn test_normalize_url_removes_trailing_slash() {
assert_eq!(
AuthManager::normalize_url("https://jellyfin.example.com/"),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url("jellyfin.example.com/"),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - trims whitespace
#[test]
fn test_normalize_url_trims_whitespace() {
assert_eq!(
AuthManager::normalize_url(" jellyfin.example.com "),
"https://jellyfin.example.com"
);
assert_eq!(
AuthManager::normalize_url(" https://jellyfin.example.com/ "),
"https://jellyfin.example.com"
);
}
/// Test URL normalization - complex case
///
/// This is the bug that caused the login issue: user enters URL
/// without protocol, it gets stored in DB, then fails when building
/// HTTP requests.
#[test]
fn test_normalize_url_real_world_case() {
// User input: "jellyfin.tourolle.paris"
let input = "jellyfin.tourolle.paris";
let normalized = AuthManager::normalize_url(input);
assert_eq!(normalized, "https://jellyfin.tourolle.paris");
assert!(normalized.starts_with("https://"));
assert!(!normalized.ends_with('/'));
}
}
-158
View File
@@ -1,158 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use super::{AuthManager, User};
// Verification interval (5 minutes)
const VERIFICATION_INTERVAL_MS: u64 = 300000;
/// Session verification result event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum SessionVerificationEvent {
Verified { user: User },
NeedsReauth { reason: String },
NetworkError { message: String },
}
/// Background session verifier
pub struct SessionVerifier {
auth_manager: Arc<AuthManager>,
is_running: Arc<AtomicBool>,
device_id: String,
app_handle: Option<AppHandle>,
}
impl SessionVerifier {
/// Create a new session verifier
pub fn new(auth_manager: Arc<AuthManager>, device_id: String) -> Self {
Self {
auth_manager,
is_running: Arc::new(AtomicBool::new(false)),
device_id,
app_handle: None,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
/// Start periodic session verification
pub async fn start(&self) {
if self.is_running.swap(true, Ordering::SeqCst) {
log::info!("[SessionVerifier] Already running");
return;
}
log::info!("[SessionVerifier] Starting background verification");
let auth_manager = Arc::clone(&self.auth_manager);
let is_running = Arc::clone(&self.is_running);
let device_id = self.device_id.clone();
let app_handle = self.app_handle.clone();
tokio::spawn(async move {
// Initial verification after short delay
tokio::time::sleep(Duration::from_millis(2000)).await;
while is_running.load(Ordering::SeqCst) {
// Get current session
let session = auth_manager.get_session().await;
if let Some(session) = session {
log::debug!("[SessionVerifier] Verifying session for: {}", session.username);
// Verify the session
match auth_manager
.verify_session(
&session.server_url,
&session.user_id,
&session.access_token,
&device_id,
)
.await
{
Ok(user) => {
log::info!("[SessionVerifier] Session verified successfully");
// Emit success event
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::Verified { user };
if let Err(e) = app.emit("auth:session-verified", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session as verified
let mut updated_session = session;
updated_session.verified = true;
updated_session.needs_reauth = false;
auth_manager.set_session(Some(updated_session)).await;
}
Err(e) => {
log::warn!("[SessionVerifier] Verification failed: {}", e);
// Classify error
let is_auth_error = e.contains("401") || e.contains("403");
let is_network_error = e.contains("network")
|| e.contains("timeout")
|| e.contains("connection")
|| e.contains("DNS");
if is_auth_error {
// Token is invalid - need re-authentication
log::warn!("[SessionVerifier] Session requires re-authentication");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NeedsReauth {
reason: "Session expired".to_string(),
};
if let Err(e) = app.emit("auth:needs-reauth", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
// Update session
let mut updated_session = session;
updated_session.verified = false;
updated_session.needs_reauth = true;
auth_manager.set_session(Some(updated_session)).await;
} else if is_network_error {
// Network error - keep using cached session
log::info!("[SessionVerifier] Network error during verification, keeping cached session");
if let Some(app) = &app_handle {
let event = SessionVerificationEvent::NetworkError {
message: e.clone(),
};
if let Err(e) = app.emit("auth:network-error", event) {
log::error!("[SessionVerifier] Failed to emit event: {}", e);
}
}
} else {
// Unknown error - log but don't invalidate
log::error!("[SessionVerifier] Unknown error during verification: {}", e);
}
}
}
}
// Wait for next verification
tokio::time::sleep(Duration::from_millis(VERIFICATION_INTERVAL_MS)).await;
}
log::info!("[SessionVerifier] Stopped");
});
}
/// Stop periodic verification
pub fn stop(&self) {
log::info!("[SessionVerifier] Stopping background verification");
self.is_running.store(false, Ordering::SeqCst);
}
}
-239
View File
@@ -1,239 +0,0 @@
use std::sync::Arc;
use tauri::State;
use crate::auth::{AuthManager, SessionVerifier, ServerInfo, AuthResult, Session};
/// Wrapper for AuthManager to manage in Tauri state
pub struct AuthManagerWrapper(pub Arc<AuthManager>);
/// Wrapper for SessionVerifier to manage in Tauri state
pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerifier>>>);
/// Initialize the auth manager (call on app startup)
/// Restores session from storage if available
#[tauri::command]
pub async fn auth_initialize(
auth_manager: State<'_, AuthManagerWrapper>,
database: State<'_, crate::commands::DatabaseWrapper>,
credentials: State<'_, crate::commands::CredentialStoreWrapper>,
) -> Result<Option<Session>, String> {
// First check if we already have a session in memory
if let Some(session) = auth_manager.0.get_session().await {
return Ok(Some(session));
}
// Try to restore session from storage
log::info!("[AuthManager] Restoring session from storage...");
// Use the existing storage_get_active_session function
let active_session = match crate::commands::storage::storage_get_active_session(database, credentials).await {
Ok(Some(session)) => session,
Ok(None) => {
log::info!("[AuthManager] No active session in storage");
return Ok(None);
}
Err(e) => {
log::error!("[AuthManager] Failed to get active session: {}", e);
return Err(e);
}
};
// Create session object from active session with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&active_session.server_url);
let session = Session {
user_id: active_session.user_id,
username: active_session.username,
server_id: active_session.server_id,
server_url: normalized_url,
server_name: active_session.server_name,
access_token: active_session.access_token,
verified: false, // Will be verified in background
needs_reauth: false,
};
// Store in AuthManager
auth_manager.0.set_session(Some(session.clone())).await;
log::info!("[AuthManager] Session restored for user: {} with normalized URL: {}", session.username, session.server_url);
Ok(Some(session))
}
/// Connect to a Jellyfin server and get server info
#[tauri::command]
pub async fn auth_connect_to_server(
server_url: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<ServerInfo, String> {
auth_manager.0.connect_to_server(&server_url).await
}
/// Login with username and password
#[tauri::command]
pub async fn auth_login(
server_url: String,
username: String,
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
let result = auth_manager.0.login(&server_url, &username, &password, &device_id).await?;
// Create session from auth result with normalized URL
let normalized_url = crate::auth::AuthManager::normalize_url(&server_url);
let session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: normalized_url,
server_name: String::new(), // Will be set by frontend
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(session)).await;
Ok(result)
}
/// Verify current session
#[tauri::command]
pub async fn auth_verify_session(
server_url: String,
user_id: String,
access_token: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<bool, String> {
match auth_manager.0.verify_session(&server_url, &user_id, &access_token, &device_id).await {
Ok(_) => Ok(true),
Err(e) => {
log::warn!("[AuthCommands] Session verification failed: {}", e);
Ok(false)
}
}
}
/// Logout (clear session and call Jellyfin logout endpoint)
#[tauri::command]
pub async fn auth_logout(
server_url: String,
access_token: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
// Stop session verification
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
drop(verifier_guard);
// Call Jellyfin logout endpoint
auth_manager.0.logout(&server_url, &access_token, &device_id).await?;
// Clear session
auth_manager.0.set_session(None).await;
Ok(())
}
/// Get current session
#[tauri::command]
pub async fn auth_get_session(
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<Option<Session>, String> {
Ok(auth_manager.0.get_session().await)
}
/// Set current session (for restoration from storage)
#[tauri::command]
pub async fn auth_set_session(
session: Option<Session>,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<(), String> {
// Normalize the server URL if session is provided
let normalized_session = session.map(|mut s| {
s.server_url = crate::auth::AuthManager::normalize_url(&s.server_url);
s
});
auth_manager.0.set_session(normalized_session).await;
Ok(())
}
/// Start background session verification
#[tauri::command]
pub async fn auth_start_verification(
device_id: String,
app_handle: tauri::AppHandle,
auth_manager: State<'_, AuthManagerWrapper>,
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
// Stop existing verifier if any
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
// Get AuthManager Arc
let manager = auth_manager.0.clone();
// Create new verifier
let mut verifier = SessionVerifier::new(manager, device_id);
verifier.set_app_handle(app_handle);
verifier.start().await;
*verifier_guard = Some(verifier);
Ok(())
}
/// Stop background session verification
#[tauri::command]
pub async fn auth_stop_verification(
session_verifier: State<'_, SessionVerifierWrapper>,
) -> Result<(), String> {
let mut verifier_guard = session_verifier.0.lock().await;
if let Some(verifier) = verifier_guard.take() {
verifier.stop();
}
Ok(())
}
/// Re-authenticate with password (when session expired)
#[tauri::command]
pub async fn auth_reauthenticate(
password: String,
device_id: String,
auth_manager: State<'_, AuthManagerWrapper>,
) -> Result<AuthResult, String> {
// Get current session to extract server_url and username
let session = auth_manager.0.get_session().await
.ok_or_else(|| "No active session to re-authenticate".to_string())?;
// Re-login with stored credentials
let result = auth_manager.0.login(&session.server_url, &session.username, &password, &device_id).await?;
// Update session with new token
let updated_session = Session {
user_id: result.user.id.clone(),
username: result.user.name.clone(),
server_id: result.server_id.clone(),
server_url: session.server_url,
server_name: session.server_name,
access_token: result.access_token.clone(),
verified: true,
needs_reauth: false,
};
auth_manager.0.set_session(Some(updated_session)).await;
Ok(result)
}
-76
View File
@@ -1,76 +0,0 @@
use std::sync::Arc;
use tauri::State;
use crate::connectivity::{ConnectivityMonitor, ConnectivityStatus};
/// Wrapper for ConnectivityMonitor managed state
pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMonitor>>);
/// Check if the server is currently reachable
#[tauri::command]
pub async fn connectivity_check_server(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<bool, String> {
let monitor = state.0.lock().await;
Ok(monitor.check_reachability().await)
}
/// Set the server URL and trigger an immediate check
#[tauri::command]
pub async fn connectivity_set_server_url(
url: String,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.set_server_url(url).await;
Ok(())
}
/// Get the current connectivity status
#[tauri::command]
pub async fn connectivity_get_status(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<ConnectivityStatus, String> {
let monitor = state.0.lock().await;
Ok(monitor.get_status().await)
}
/// Start monitoring connectivity with adaptive polling
#[tauri::command]
pub async fn connectivity_start_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.start_monitoring().await;
Ok(())
}
/// Stop monitoring connectivity
#[tauri::command]
pub async fn connectivity_stop_monitoring(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.stop_monitoring();
Ok(())
}
/// Mark the server as reachable (called after successful API calls)
#[tauri::command]
pub async fn connectivity_mark_reachable(
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_reachable().await;
Ok(())
}
/// Mark the server as unreachable (called after failed API calls)
#[tauri::command]
pub async fn connectivity_mark_unreachable(
error: Option<String>,
state: State<'_, ConnectivityMonitorWrapper>,
) -> Result<(), String> {
let monitor = state.0.lock().await;
monitor.mark_unreachable(error).await;
Ok(())
}
-74
View File
@@ -1,74 +0,0 @@
//! Tauri commands for unit conversions and formatting
//!
//! These commands expose conversion utilities to the frontend,
//! allowing centralized conversion logic in Rust.
use crate::utils::conversions::{
format_time, format_time_long, calculate_progress,
ticks_to_seconds, percent_to_volume,
};
/// Format time in seconds to MM:SS display string
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "3:45" or "12:09"
#[tauri::command]
pub fn format_time_seconds(seconds: f64) -> String {
format_time(seconds)
}
/// Format time in seconds to HH:MM:SS or MM:SS display string
///
/// Automatically chooses format based on duration:
/// - Less than 1 hour: Returns MM:SS format
/// - 1 hour or more: Returns HH:MM:SS format
///
/// # Arguments
/// * `seconds` - Time in seconds
///
/// # Returns
/// Formatted string like "1:23:45" or "3:45"
#[tauri::command]
pub fn format_time_seconds_long(seconds: f64) -> String {
format_time_long(seconds)
}
/// Convert Jellyfin ticks to seconds
///
/// # Arguments
/// * `ticks` - Time in Jellyfin ticks (10,000,000 ticks = 1 second)
///
/// # Returns
/// Time in seconds
#[tauri::command]
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
ticks_to_seconds(ticks)
}
/// Calculate progress percentage from position and duration
///
/// # Arguments
/// * `position` - Current position in seconds
/// * `duration` - Total duration in seconds
///
/// # Returns
/// Progress as percentage (0.0 to 100.0)
#[tauri::command]
pub fn calc_progress(position: f64, duration: f64) -> f64 {
calculate_progress(position, duration)
}
/// Convert percentage volume (0-100) to normalized (0.0-1.0)
///
/// # Arguments
/// * `percent` - Volume as percentage (0 to 100)
///
/// # Returns
/// Normalized volume (0.0 to 1.0)
#[tauri::command]
pub fn convert_percent_to_volume(percent: f64) -> f64 {
percent_to_volume(percent)
}
-128
View File
@@ -1,128 +0,0 @@
//! Device identification commands
//!
//! Handles persistent device ID generation and retrieval for Jellyfin server communication.
//! TRACES: UR-009 | DR-011
use std::sync::Arc;
use log::info;
use tauri::State;
use uuid::Uuid;
use crate::commands::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Get or create the device ID.
/// Device ID is a UUID v4 that persists across app restarts.
/// On first call, generates and stores a new UUID.
/// On subsequent calls, retrieves the stored UUID.
///
/// # Returns
/// - `Ok(String)` - The device ID (UUID v4)
/// - `Err(String)` - If database operation fails
///
/// TRACES: UR-009 | DR-011
#[tauri::command]
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Try to get existing device ID from database
let query = Query::with_params(
"SELECT value FROM app_settings WHERE key = ?",
vec![QueryParam::String("device_id".to_string())],
);
let existing_id: Option<String> = db_service
.query_one(query, |row| row.get(0))
.await
.ok()
.flatten();
if let Some(device_id) = existing_id {
info!("[Device] Retrieved existing device ID");
return Ok(device_id);
}
// Generate new device ID
let device_id = Uuid::new_v4().to_string();
// Store it in database
let insert_query = Query::with_params(
"INSERT INTO app_settings (key, value) VALUES (?, ?)",
vec![
QueryParam::String("device_id".to_string()),
QueryParam::String(device_id.clone()),
],
);
db_service
.execute(insert_query)
.await
.map_err(|e| e.to_string())?;
info!("[Device] Generated and stored new device ID");
Ok(device_id)
}
/// Set the device ID (primarily for testing or recovery).
/// Overwrites any existing device ID.
///
/// # Arguments
/// * `device_id` - The device ID to store (should be UUID v4 format)
///
/// # Returns
/// - `Ok(())` - If device ID was stored successfully
/// - `Err(String)` - If database operation fails
///
/// TRACES: UR-009 | DR-011
#[tauri::command]
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
vec![
QueryParam::String("device_id".to_string()),
QueryParam::String(device_id),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
info!("[Device] Device ID set");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_device_id_is_valid_uuid() {
let id = Uuid::new_v4().to_string();
// Should parse as UUID
let parsed = Uuid::parse_str(&id);
assert!(parsed.is_ok(), "Device ID should be a valid UUID");
}
#[test]
fn test_device_id_format() {
let id = Uuid::new_v4().to_string();
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx (36 chars with hyphens)
assert_eq!(id.len(), 36, "Device ID should be 36 characters");
assert!(id.contains('-'), "Device ID should contain hyphens");
}
#[test]
fn test_device_ids_are_unique() {
let id1 = Uuid::new_v4().to_string();
let id2 = Uuid::new_v4().to_string();
assert_ne!(id1, id2, "Generated device IDs should be unique");
}
}
File diff suppressed because it is too large Load Diff
-31
View File
@@ -1,31 +0,0 @@
// Tauri commands exposed to frontend
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
// DR-015, DR-017, DR-021, DR-028
pub mod auth;
pub mod connectivity;
pub mod conversions;
pub mod device;
pub mod download;
pub mod offline;
pub mod playback_mode;
pub mod playback_reporting;
pub mod player;
pub mod repository;
pub mod sessions;
pub mod storage;
pub mod sync;
pub use auth::*;
pub use connectivity::*;
pub use conversions::*;
pub use device::*;
pub use download::*;
pub use offline::*;
pub use playback_mode::*;
#[allow(unused_imports)] // Used when playback_reporting is fully integrated
pub use playback_reporting::*;
pub use player::*;
pub use repository::{*, RepositoryManager, RepositoryManagerWrapper};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
-155
View File
@@ -1,155 +0,0 @@
//! Tauri commands for offline data access
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OfflineItem {
pub id: String,
pub name: String,
pub item_type: String,
pub album_id: Option<String>,
pub album_name: Option<String>,
pub artists: Option<String>,
pub runtime_ticks: Option<i64>,
pub primary_image_tag: Option<String>,
}
/// Check if an item is available offline
#[tauri::command]
pub async fn offline_is_available(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<bool, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM downloads WHERE item_id = ? AND status = 'completed'",
vec![QueryParam::String(item_id)],
);
let count: i64 = db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
Ok(count > 0)
}
/// Get all offline items for a user
#[tauri::command]
pub async fn offline_get_items(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
ORDER BY d.completed_at DESC",
vec![QueryParam::String(user_id)],
);
db_service
.query_many(query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Search offline items
#[tauri::command]
pub async fn offline_search(
db: State<'_, DatabaseWrapper>,
user_id: String,
query: String,
) -> Result<Vec<OfflineItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let search_query = format!("%{}%", query.to_lowercase());
let db_query = Query::with_params(
"SELECT i.id, i.name, i.item_type, i.album_id, i.album_name, i.artists,
i.runtime_ticks, i.primary_image_tag
FROM items i
INNER JOIN downloads d ON i.id = d.item_id
WHERE d.user_id = ? AND d.status = 'completed'
AND (LOWER(i.name) LIKE ? OR LOWER(i.artists) LIKE ? OR LOWER(i.album_name) LIKE ?)
ORDER BY i.name
LIMIT 50",
vec![
QueryParam::String(user_id),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query.clone()),
QueryParam::String(search_query),
],
);
db_service
.query_many(db_query, |row| {
Ok(OfflineItem {
id: row.get(0)?,
name: row.get(1)?,
item_type: row.get(2)?,
album_id: row.get(3)?,
album_name: row.get(4)?,
artists: row.get(5)?,
runtime_ticks: row.get(6)?,
primary_image_tag: row.get(7)?,
})
})
.await
.map_err(|e| e.to_string())
}
// TRACES: UR-002, UR-011 | DR-017 | UT-044
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_offline_item_serialization() {
let item = OfflineItem {
id: "123".to_string(),
name: "Test Song".to_string(),
item_type: "Audio".to_string(),
album_id: Some("album1".to_string()),
album_name: Some("Test Album".to_string()),
artists: Some("Artist 1".to_string()),
runtime_ticks: Some(180000000),
primary_image_tag: Some("tag123".to_string()),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"itemType\":\"Audio\""));
assert!(json.contains("\"albumName\":\"Test Album\""));
}
}
-129
View File
@@ -1,129 +0,0 @@
use std::sync::Arc;
use tauri::State;
use crate::playback_mode::{PlaybackMode, PlaybackModeManager};
/// Wrapper for PlaybackModeManager to manage in Tauri state
pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
/// Get the current playback mode
#[tauri::command]
pub fn playback_mode_get_current(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<PlaybackMode, String> {
Ok(manager.0.get_mode())
}
/// Set the playback mode (internal/testing use)
#[tauri::command]
pub fn playback_mode_set(
manager: State<'_, PlaybackModeManagerWrapper>,
mode: PlaybackMode,
) -> Result<(), String> {
manager.0.set_mode(mode);
Ok(())
}
/// Check if currently transferring between playback modes
#[tauri::command]
pub fn playback_mode_is_transferring(
manager: State<'_, PlaybackModeManagerWrapper>,
) -> Result<bool, String> {
Ok(manager.0.is_transferring())
}
/// Transfer playback from local device to a remote Jellyfin session
#[tauri::command]
pub async fn playback_mode_transfer_to_remote(
manager: State<'_, PlaybackModeManagerWrapper>,
session_id: String,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to remote session: {}",
session_id
);
manager.0.transfer_to_remote(session_id).await
}
/// Transfer playback from remote session back to local device
///
/// Parameters:
/// - current_item_id: The Jellyfin item ID currently playing on remote
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
#[tauri::command]
pub async fn playback_mode_transfer_to_local(
manager: State<'_, PlaybackModeManagerWrapper>,
current_item_id: String,
position_ticks: i64,
) -> Result<(), String> {
log::info!(
"[PlaybackModeCommands] Transferring to local: item_id={}, position={}",
current_item_id,
position_ticks
);
manager
.0
.transfer_to_local(current_item_id, position_ticks)
.await
}
/// Get remote session status (for polling position/duration)
#[tauri::command]
pub async fn playback_mode_get_remote_status(
manager: State<'_, PlaybackModeManagerWrapper>,
player: State<'_, crate::commands::PlayerStateWrapper>,
) -> Result<RemoteSessionStatus, String> {
let mode = manager.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Get Jellyfin client from player controller - clone before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt.as_ref().ok_or("Jellyfin client not configured")?.clone()
};
// Get session info
match client.get_session(&session_id).await {
Ok(Some(session)) => {
let position_ticks = session.play_state.as_ref()
.and_then(|ps| ps.position_ticks)
.unwrap_or(0);
let duration_ticks = session.now_playing_item.as_ref()
.and_then(|item| item.run_time_ticks)
.unwrap_or(0);
let is_paused = session.play_state.as_ref()
.and_then(|ps| ps.is_paused)
.unwrap_or(true);
Ok(RemoteSessionStatus {
position: position_ticks as f64 / 10_000_000.0,
duration: if duration_ticks > 0 {
Some(duration_ticks as f64 / 10_000_000.0)
} else {
None
},
is_playing: !is_paused,
now_playing_item: session.now_playing_item.clone(),
})
}
Ok(None) => Err("Remote session not found".to_string()),
Err(e) => Err(format!("Failed to get session status: {}", e)),
}
} else {
Err("Not in remote playback mode".to_string())
}
}
/// Remote session status for UI updates
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSessionStatus {
pub position: f64,
pub duration: Option<f64>,
pub is_playing: bool,
pub now_playing_item: Option<crate::jellyfin::NowPlayingItem>,
}
@@ -1,184 +0,0 @@
//! Tauri commands for playback reporting operations
//!
//! These commands provide frontend access to the Rust playback reporting system,
//! replacing the TypeScript implementation with native Rust reporting.
//!
//! Commands are registered but not yet called from the frontend.
//! Dead code warnings are suppressed until frontend migration is complete.
#![allow(dead_code)]
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
use crate::commands::connectivity::ConnectivityMonitorWrapper;
use crate::commands::storage::DatabaseWrapper;
use crate::jellyfin::client::JellyfinClient;
use crate::jellyfin::JellyfinConfig;
use crate::playback_reporting::{PlaybackReporter, PlaybackOperation, PlaybackContext};
use crate::utils::conversions::seconds_to_ticks;
/// Tauri state wrapper for PlaybackReporter
pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>);
/// Initialize playback reporter (called after login)
#[tauri::command]
pub async fn playback_reporter_init(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Initializing for user: {}", user_id);
// Get database service
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
// Create JellyfinClient
let jellyfin_config = JellyfinConfig {
server_url,
access_token,
device_id,
};
let jellyfin_client = JellyfinClient::new(jellyfin_config)
.map_err(|e| format!("Failed to create JellyfinClient: {}", e))?;
// Create PlaybackReporter
let reporter = PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(jellyfin_client))),
user_id.clone(),
);
// Store in wrapper
*reporter_wrapper.0.lock().await = Some(reporter);
log::info!("[PlaybackReporter] Initialized successfully for user: {}", user_id);
Ok(())
}
/// Destroy playback reporter (called on logout)
#[tauri::command]
pub async fn playback_reporter_destroy(
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
) -> Result<(), String> {
log::info!("[PlaybackReporter] Destroying reporter");
*reporter_wrapper.0.lock().await = None;
Ok(())
}
/// Report playback start
#[tauri::command]
pub async fn playback_report_start(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
context_type: Option<String>,
context_id: Option<String>,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let context = context_type.map(|ct| PlaybackContext {
context_type: ct,
context_id,
});
let operation = PlaybackOperation::Start {
item_id,
position_ticks,
context,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback progress
#[tauri::command]
pub async fn playback_report_progress(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
is_paused: bool,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Progress {
item_id,
position_ticks,
is_paused,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Report playback stopped
#[tauri::command]
pub async fn playback_report_stopped(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
position_seconds: f64,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let position_ticks = seconds_to_ticks(position_seconds);
let operation = PlaybackOperation::Stopped {
item_id,
position_ticks,
};
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
/// Mark item as played
#[tauri::command]
pub async fn playback_mark_played(
reporter: State<'_, PlaybackReporterWrapper>,
connectivity: State<'_, ConnectivityMonitorWrapper>,
item_id: String,
) -> Result<(), String> {
let reporter_guard = reporter.0.lock().await;
let reporter_instance = reporter_guard
.as_ref()
.ok_or("PlaybackReporter not initialized")?;
let operation = PlaybackOperation::MarkPlayed { item_id };
let monitor = connectivity.0.lock().await;
let is_online = monitor.get_status().await.is_server_reachable;
drop(monitor);
reporter_instance.report(operation, is_online).await
}
File diff suppressed because it is too large Load Diff
-462
View File
@@ -1,462 +0,0 @@
// Tauri commands for repository access
// Uses handle-based system: UUID -> Arc<HybridRepository>
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use log::{debug, error, info};
use tauri::State;
use uuid::Uuid;
use crate::jellyfin::HttpClient;
use crate::repository::{HybridRepository, MediaRepository, OnlineRepository, OfflineRepository, types::*};
/// Repository handle manager
pub struct RepositoryManager {
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
}
impl RepositoryManager {
pub fn new() -> Self {
Self {
repositories: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn create(&self, handle: String, repository: HybridRepository) {
let mut repos = self.repositories.lock().unwrap();
repos.insert(handle, Arc::new(repository));
}
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
let repos = self.repositories.lock().unwrap();
repos.get(handle).cloned()
}
pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock().unwrap();
repos.remove(handle);
}
}
/// Wrapper for Tauri state
pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository
#[tauri::command]
pub async fn repository_create(
manager: State<'_, RepositoryManagerWrapper>,
db: State<'_, crate::commands::storage::DatabaseWrapper>,
server_url: String,
user_id: String,
access_token: String,
server_id: String,
) -> Result<String, String> {
info!("[REPO] repository_create called for user: {}", user_id);
// Create HTTP client for online repository
debug!("[REPO] Creating HTTP client...");
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = HttpClient::new(http_config).map_err(|e| {
error!("[REPO] HTTP client creation failed: {}", e);
e.to_string()
})?;
debug!("[REPO] HTTP client created successfully");
// Create online repository
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(Arc::new(http_client), server_url, user_id.clone(), access_token);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
debug!("[REPO] Creating database service...");
let db_service = {
let database = db.0.lock().map_err(|e| {
error!("[REPO] Database lock failed: {}", e);
e.to_string()
})?;
debug!("[REPO] Database lock acquired, getting service...");
Arc::new(database.service())
}; // Lock is released here
debug!("[REPO] Database service created");
debug!("[REPO] Creating offline repository...");
let offline = OfflineRepository::new(db_service, server_id, user_id);
debug!("[REPO] Offline repository created");
// Create hybrid repository
debug!("[REPO] Creating hybrid repository...");
let hybrid = HybridRepository::new(online, offline);
debug!("[REPO] Hybrid repository created");
// Generate handle and store repository
let uuid = Uuid::new_v4();
let handle = format!("{}", uuid);
info!("[REPO] Generated handle: {}", handle);
// Store repository synchronously
debug!("[REPO] Storing repository...");
manager.0.create(handle.clone(), hybrid);
info!("[REPO] Repository stored successfully");
Ok(handle)
}
/// Destroy a repository instance
#[tauri::command]
pub async fn repository_destroy(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<(), String> {
manager.0.destroy(&handle);
Ok(())
}
/// Get libraries
#[tauri::command]
pub async fn repository_get_libraries(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
) -> Result<Vec<Library>, String> {
debug!("[REPO] get_libraries called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching libraries...");
repo.as_ref().get_libraries()
.await
.map_err(|e| {
error!("[REPO] Error fetching libraries: {:?}", e);
format!("{:?}", e)
})
}
/// Get items in a container (library, folder, album, etc.)
#[tauri::command]
pub async fn repository_get_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items(&parent_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get a single item by ID
#[tauri::command]
pub async fn repository_get_item(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_item(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get latest items in a library
#[tauri::command]
pub async fn repository_get_latest_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_latest_items(&parent_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume items (continue watching/listening)
#[tauri::command]
pub async fn repository_get_resume_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
debug!("[REPO] get_resume_items called with handle: {}", handle);
let repo = manager.0.get(&handle).ok_or_else(|| {
error!("[REPO] Repository not found for handle: {}", handle);
"Repository not found".to_string()
})?;
debug!("[REPO] Repository found, fetching resume items...");
repo.as_ref().get_resume_items(parent_id.as_deref(), limit)
.await
.map_err(|e| {
error!("[REPO] Error fetching resume items: {:?}", e);
format!("{:?}", e)
})
}
/// Get next up episodes
#[tauri::command]
pub async fn repository_get_next_up_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: Option<String>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_next_up_episodes(series_id.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get recently played audio
#[tauri::command]
pub async fn repository_get_recently_played_audio(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_recently_played_audio(limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get resume movies
#[tauri::command]
pub async fn repository_get_resume_movies(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_resume_movies(limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get genres for a library
#[tauri::command]
pub async fn repository_get_genres(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
parent_id: Option<String>,
) -> Result<Vec<Genre>, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_genres(parent_id.as_deref())
.await
.map_err(|e| format!("{:?}", e))
}
/// Search for items
#[tauri::command]
pub async fn repository_search(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
query: String,
options: Option<SearchOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().search(&query, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get playback info for an item
#[tauri::command]
pub async fn repository_get_playback_info(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<PlaybackInfo, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_playback_info(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get video stream URL with optional seeking support
#[tauri::command]
pub async fn repository_get_video_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: Option<String>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_video_stream_url(
&item_id,
media_source_id.as_deref(),
start_time_seconds,
audio_stream_index,
)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get audio stream URL for a track
#[tauri::command]
pub async fn repository_get_audio_stream_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref()
.get_audio_stream_url(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback start
#[tauri::command]
pub async fn repository_report_playback_start(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_start(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback progress
#[tauri::command]
pub async fn repository_report_playback_progress(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_progress(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Report playback stopped
#[tauri::command]
pub async fn repository_report_playback_stopped(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
position_ticks: i64,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().report_playback_stopped(&item_id, position_ticks)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get image URL for an item
#[tauri::command]
pub fn repository_get_image_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
image_type: ImageType,
options: Option<ImageOptions>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_image_url(&item_id, image_type, options))
}
/// Get subtitle URL for a media item
#[tauri::command]
pub fn repository_get_subtitle_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
media_source_id: String,
stream_index: i32,
format: String,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_subtitle_url(&item_id, &media_source_id, stream_index, &format))
}
/// Get video download URL with quality preset
#[tauri::command]
pub fn repository_get_video_download_url(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
quality: String,
media_source_id: Option<String>,
) -> Result<String, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
Ok(repo.as_ref().get_video_download_url(&item_id, &quality, media_source_id.as_deref()))
}
/// Mark an item as favorite
#[tauri::command]
pub async fn repository_mark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().mark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Unmark an item as favorite
#[tauri::command]
pub async fn repository_unmark_favorite(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().unmark_favorite(&item_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get person details
#[tauri::command]
pub async fn repository_get_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
) -> Result<MediaItem, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_person(&person_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get items by person (actor, director, etc.)
#[tauri::command]
pub async fn repository_get_items_by_person(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
person_id: String,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_items_by_person(&person_id, options)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get similar/related items for a media item
#[tauri::command]
pub async fn repository_get_similar_items(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
limit: Option<usize>,
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
repo.as_ref().get_similar_items(&item_id, limit)
.await
.map_err(|e| format!("{:?}", e))
}
-32
View File
@@ -1,32 +0,0 @@
use std::sync::Arc;
use tauri::State;
use crate::session_poller::{PollingHint, SessionPollerManager};
use crate::jellyfin::client::SessionInfo;
/// Tauri state wrapper for SessionPollerManager
pub struct SessionPollerWrapper(pub Arc<SessionPollerManager>);
/// Set polling frequency hint based on UI state
#[tauri::command]
pub fn sessions_set_polling_hint(
poller: State<'_, SessionPollerWrapper>,
hint: String,
) -> Result<(), String> {
let parsed_hint = match hint.as_str() {
"cast_active" => PollingHint::CastActive,
"cast_discovery" => PollingHint::CastDiscovery,
"normal" => PollingHint::Normal,
_ => return Err(format!("Invalid polling hint: {}", hint)),
};
poller.0.set_polling_hint(parsed_hint);
Ok(())
}
/// Manually trigger a session poll (for refresh button)
#[tauri::command]
pub async fn sessions_poll_now(
poller: State<'_, SessionPollerWrapper>,
) -> Result<Vec<SessionInfo>, String> {
poller.0.poll_now().await
}
File diff suppressed because it is too large Load Diff
-236
View File
@@ -1,236 +0,0 @@
//! Tauri commands for sync queue operations
//!
//! The sync queue stores mutations (favorites, playback progress, etc.)
//! that need to be synced to the Jellyfin server when connectivity is restored.
//! TRACES: UR-002, UR-017, UR-025 | DR-014
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use super::storage::DatabaseWrapper;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// Sync queue item returned to frontend
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncQueueItem {
pub id: i64,
pub user_id: String,
pub operation: String,
pub item_id: Option<String>,
pub payload: Option<String>,
pub status: String,
pub retry_count: i32,
pub created_at: Option<String>,
pub error_message: Option<String>,
}
/// Queue a mutation for sync to server
#[tauri::command]
pub async fn sync_queue_mutation(
db: State<'_, DatabaseWrapper>,
user_id: String,
operation: String,
item_id: Option<String>,
payload: Option<String>,
) -> Result<i64, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
vec![
QueryParam::String(user_id),
QueryParam::String(operation),
item_id.map(QueryParam::String).unwrap_or(QueryParam::Null),
payload.map(QueryParam::String).unwrap_or(QueryParam::Null),
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service.last_insert_rowid().await.map_err(|e| e.to_string())?;
Ok(id)
}
/// Get all pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending(
db: State<'_, DatabaseWrapper>,
user_id: String,
limit: Option<i32>,
) -> Result<Vec<SyncQueueItem>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let sql = if let Some(l) = limit {
format!(
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC
LIMIT {}",
l
)
} else {
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC".to_string()
};
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
db_service
.query_many(query, |row| {
Ok(SyncQueueItem {
id: row.get(0)?,
user_id: row.get(1)?,
operation: row.get(2)?,
item_id: row.get(3)?,
payload: row.get(4)?,
status: row.get(5)?,
retry_count: row.get(6)?,
created_at: row.get(7)?,
error_message: row.get(8)?,
})
})
.await
.map_err(|e| e.to_string())
}
/// Mark a sync operation as in progress
#[tauri::command]
pub async fn sync_mark_processing(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'processing' WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as completed
#[tauri::command]
pub async fn sync_mark_completed(
db: State<'_, DatabaseWrapper>,
id: i64,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue SET status = 'completed', processed_at = CURRENT_TIMESTAMP WHERE id = ?",
vec![QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Mark a sync operation as failed with error message
#[tauri::command]
pub async fn sync_mark_failed(
db: State<'_, DatabaseWrapper>,
id: i64,
error: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"UPDATE sync_queue
SET status = 'failed',
retry_count = retry_count + 1,
error_message = ?,
processed_at = CURRENT_TIMESTAMP
WHERE id = ?",
vec![QueryParam::String(error), QueryParam::Int64(id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
/// Get count of pending sync operations for a user
#[tauri::command]
pub async fn sync_get_pending_count(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
vec![QueryParam::String(user_id)],
);
db_service
.query_one(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())
}
/// Delete completed sync operations older than specified days
#[tauri::command]
pub async fn sync_cleanup_completed(
db: State<'_, DatabaseWrapper>,
days_old: i32,
) -> Result<i32, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue
WHERE status = 'completed'
AND processed_at < datetime('now', ?)",
vec![QueryParam::String(format!("-{} days", days_old))],
);
let deleted = db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(deleted as i32)
}
/// Delete all sync operations for a user (used during logout)
#[tauri::command]
pub async fn sync_clear_user(
db: State<'_, DatabaseWrapper>,
user_id: String,
) -> Result<(), String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let query = Query::with_params(
"DELETE FROM sync_queue WHERE user_id = ?",
vec![QueryParam::String(user_id)],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
Ok(())
}
-370
View File
@@ -1,370 +0,0 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tauri::{AppHandle, Emitter};
use serde::{Serialize, Deserialize};
use crate::jellyfin::http_client::HttpClient;
// Adaptive polling intervals (matches TypeScript)
const AUTO_CHECK_INTERVAL_MS: u64 = 30000; // 30 seconds when online
const RETRY_CHECK_INTERVAL_MS: u64 = 5000; // 5 seconds when offline
/// Connectivity status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectivityStatus {
/// Whether the Jellyfin server is reachable
pub is_server_reachable: bool,
/// Last time we checked server reachability (ISO 8601 string)
pub last_checked: Option<String>,
/// Error message from last connectivity check
pub connection_error: Option<String>,
/// Whether we're currently checking connectivity
pub is_checking: bool,
}
impl Default for ConnectivityStatus {
fn default() -> Self {
Self {
// Start optimistic - assume online until proven otherwise
// This prevents the app from appearing offline on startup
is_server_reachable: true,
last_checked: None,
connection_error: None,
is_checking: false,
}
}
}
/// Connectivity change event emitted to frontend
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectivityChangeEvent {
is_reachable: bool,
}
/// Connectivity monitor for tracking server reachability
pub struct ConnectivityMonitor {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
is_monitoring: Arc<AtomicBool>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitor {
/// Create a new connectivity monitor
pub fn new(http_client: HttpClient) -> Self {
Self {
server_url: Arc::new(RwLock::new(None)),
http_client: Arc::new(http_client),
status: Arc::new(RwLock::new(ConnectivityStatus::default())),
is_monitoring: Arc::new(AtomicBool::new(false)),
app_handle: None,
}
}
/// Set the Tauri app handle for event emission
pub fn set_app_handle(&mut self, app_handle: AppHandle) {
self.app_handle = Some(app_handle);
}
/// Update the server URL
pub async fn set_server_url(&self, url: String) {
log::info!("[ConnectivityMonitor] Setting server URL: {}", url);
let mut server_url = self.server_url.write().await;
*server_url = Some(url.clone());
drop(server_url);
// Check new server immediately
log::info!("[ConnectivityMonitor] Checking reachability of new server...");
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] New server is {}", if is_reachable { "REACHABLE" } else { "UNREACHABLE" });
}
/// Get current connectivity status
pub async fn get_status(&self) -> ConnectivityStatus {
self.status.read().await.clone()
}
/// Check if the Jellyfin server is reachable
pub async fn check_reachability(&self) -> bool {
// Mark as checking
{
let mut status = self.status.write().await;
status.is_checking = true;
}
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
log::warn!("[ConnectivityMonitor] Cannot check reachability: No server URL configured");
let mut status = self.status.write().await;
status.is_server_reachable = false;
status.connection_error = Some("No server URL configured".to_string());
status.is_checking = false;
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
log::debug!("[ConnectivityMonitor] Pinging server: {}", ping_url);
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
log::debug!(
"[ConnectivityMonitor] Ping result: {} (was: {})",
if is_reachable { "SUCCESS" } else { "FAILED" },
if was_reachable { "reachable" } else { "unreachable" }
);
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
status.is_checking = false;
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
/// Mark server as reachable (called after successful API call)
pub async fn mark_reachable(&self) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = true;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = None;
drop(status);
if !was_reachable {
log::info!("[ConnectivityMonitor] Server marked as reachable (was unreachable)");
self.emit_connectivity_change(true).await;
self.emit_server_reconnected().await;
}
}
/// Mark server as unreachable (called after failed API call)
pub async fn mark_unreachable(&self, error: Option<String>) {
let mut status = self.status.write().await;
let was_reachable = status.is_server_reachable;
status.is_server_reachable = false;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = error.or_else(|| Some("Server unreachable".to_string()));
let error_msg = status.connection_error.clone().unwrap_or_default();
drop(status);
if was_reachable {
log::warn!("[ConnectivityMonitor] Server marked as unreachable (was reachable): {}", error_msg);
self.emit_connectivity_change(false).await;
}
}
/// Start monitoring connectivity with adaptive polling
pub async fn start_monitoring(&self) {
if self.is_monitoring.swap(true, Ordering::SeqCst) {
log::info!("[ConnectivityMonitor] Already monitoring");
return;
}
log::info!("[ConnectivityMonitor] Starting connectivity monitoring");
// Perform immediate check before starting background task
// This ensures we get an accurate state right away instead of assuming offline
let is_reachable = self.check_reachability().await;
log::info!("[ConnectivityMonitor] Initial connectivity check: {}", if is_reachable { "ONLINE" } else { "OFFLINE" });
// Clone Arc references for the background task
let status = Arc::clone(&self.status);
let is_monitoring = Arc::clone(&self.is_monitoring);
let server_url = Arc::clone(&self.server_url);
let http_client = Arc::clone(&self.http_client);
let self_clone = Arc::new(ConnectivityMonitorHandle {
server_url,
http_client,
status,
app_handle: self.app_handle.clone(),
});
// Spawn background monitoring task
tokio::spawn(async move {
while is_monitoring.load(Ordering::SeqCst) {
// Determine interval based on current reachability
let interval_ms = {
let status = self_clone.status.read().await;
if status.is_server_reachable {
AUTO_CHECK_INTERVAL_MS
} else {
RETRY_CHECK_INTERVAL_MS
}
};
// Wait for the interval
tokio::time::sleep(Duration::from_millis(interval_ms)).await;
// Check if still monitoring
if !is_monitoring.load(Ordering::SeqCst) {
break;
}
// Perform connectivity check
let _ = self_clone.check_reachability().await;
}
log::info!("[ConnectivityMonitor] Stopped monitoring");
});
}
/// Stop monitoring connectivity
pub fn stop_monitoring(&self) {
log::info!("[ConnectivityMonitor] Stopping connectivity monitoring");
self.is_monitoring.store(false, Ordering::SeqCst);
}
/// Emit connectivity change event to frontend
async fn emit_connectivity_change(&self, is_reachable: bool) {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
} else {
log::info!("[ConnectivityMonitor] Emitted connectivity change: {}", is_reachable);
}
}
}
/// Emit server reconnected event to frontend
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
} else {
log::info!("[ConnectivityMonitor] Emitted server reconnected event");
}
}
}
}
/// Handle for the background monitoring task
struct ConnectivityMonitorHandle {
server_url: Arc<RwLock<Option<String>>>,
http_client: Arc<HttpClient>,
status: Arc<RwLock<ConnectivityStatus>>,
app_handle: Option<AppHandle>,
}
impl ConnectivityMonitorHandle {
async fn check_reachability(&self) -> bool {
let server_url = self.server_url.read().await.clone();
if server_url.is_none() {
return false;
}
let url = server_url.unwrap();
let ping_url = format!("{}/System/Info/Public", url);
// Store previous reachability state
let was_reachable = {
let status = self.status.read().await;
status.is_server_reachable
};
// Attempt to ping the server
let is_reachable = self.http_client.ping(&ping_url).await;
// Update status
{
let mut status = self.status.write().await;
status.is_server_reachable = is_reachable;
status.last_checked = Some(chrono::Utc::now().to_rfc3339());
status.connection_error = if is_reachable {
None
} else {
Some("Server unreachable".to_string())
};
}
// Emit events if reachability changed
if is_reachable != was_reachable {
self.emit_connectivity_change(is_reachable).await;
}
// Emit reconnection event
if is_reachable && !was_reachable {
self.emit_server_reconnected().await;
}
is_reachable
}
async fn emit_connectivity_change(&self, is_reachable: bool) {
if let Some(app_handle) = &self.app_handle {
let event = ConnectivityChangeEvent { is_reachable };
if let Err(e) = app_handle.emit("connectivity:changed", event) {
log::error!("[ConnectivityMonitor] Failed to emit connectivity change event: {}", e);
}
}
}
async fn emit_server_reconnected(&self) {
if let Some(app_handle) = &self.app_handle {
if let Err(e) = app_handle.emit("connectivity:reconnected", ()) {
log::error!("[ConnectivityMonitor] Failed to emit reconnection event: {}", e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::jellyfin::http_client::HttpConfig;
#[test]
fn test_intervals() {
// Verify intervals match TypeScript
assert_eq!(AUTO_CHECK_INTERVAL_MS, 30000);
assert_eq!(RETRY_CHECK_INTERVAL_MS, 5000);
}
#[tokio::test]
async fn test_default_status() {
let status = ConnectivityStatus::default();
// Default is now optimistic (assume online until proven otherwise)
assert!(status.is_server_reachable);
assert!(status.last_checked.is_none());
assert!(status.connection_error.is_none());
assert!(!status.is_checking);
}
}
-820
View File
@@ -1,820 +0,0 @@
//! Secure credential storage module
//!
//! Provides secure storage for access tokens using:
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
//!
//! The fallback is less secure as the encryption key is derived from machine
//! identifiers, but provides functionality on headless systems.
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use directories::ProjectDirs;
use log::{info, warn};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
#[cfg(target_os = "linux")]
use hostname;
#[cfg(not(target_os = "android"))]
const SERVICE_NAME: &str = "com.dtourolle.jellytau";
const CREDENTIALS_FILENAME: &str = "credentials.enc";
/// Result of a credential storage operation
#[derive(Debug)]
pub enum CredentialResult {
/// Operation succeeded using the system keyring
Keyring,
/// Operation succeeded using encrypted file fallback
EncryptedFile,
}
/// Error types for credential operations
#[derive(Debug)]
pub enum CredentialError {
/// Keyring operation failed
Keyring(String),
/// Encryption/decryption failed
Encryption(String),
/// File I/O failed
Io(String),
/// Credential not found
NotFound,
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Keyring(msg) => write!(f, "Keyring error: {}", msg),
Self::Encryption(msg) => write!(f, "Encryption error: {}", msg),
Self::Io(msg) => write!(f, "I/O error: {}", msg),
Self::NotFound => write!(f, "Credential not found"),
}
}
}
impl std::error::Error for CredentialError {}
/// Credential storage manager
pub struct CredentialStore {
/// Whether we're using keyring (true) or encrypted file (false)
using_keyring: bool,
/// Path to the encrypted credentials file (fallback)
credentials_path: PathBuf,
/// Encryption key for file fallback (derived from machine ID)
encryption_key: [u8; 32],
}
impl CredentialStore {
/// Create a new credential store, detecting the best available backend
pub fn new() -> Self {
let credentials_path = Self::get_credentials_path();
let encryption_key = Self::derive_encryption_key();
// Test if keyring is available by trying a dummy operation
let using_keyring = Self::test_keyring_available();
if !using_keyring {
warn!(
"[INIT] System keyring unavailable, using encrypted file fallback at {:?}. \
This is less secure than system keyring storage.",
credentials_path
);
} else {
info!("[INIT] Using system keyring for credential storage");
}
Self {
using_keyring,
credentials_path,
encryption_key,
}
}
/// Check if we're using the secure keyring backend
pub fn is_using_keyring(&self) -> bool {
self.using_keyring
}
/// Save an access token for a user
pub fn save_token(&self, user_id: &str, token: &str) -> Result<CredentialResult, CredentialError> {
if self.using_keyring {
log::debug!("Saving token for user {} to keyring", user_id);
self.save_to_keyring(user_id, token)?;
Ok(CredentialResult::Keyring)
} else {
log::debug!("Saving token for user {} to encrypted file at {:?}", user_id, self.credentials_path);
self.save_to_file(user_id, token)?;
log::debug!("Successfully saved token to encrypted file");
Ok(CredentialResult::EncryptedFile)
}
}
/// Get an access token for a user
pub fn get_token(&self, user_id: &str) -> Result<String, CredentialError> {
if self.using_keyring {
log::debug!("Getting token for user {} from keyring", user_id);
self.get_from_keyring(user_id)
} else {
log::debug!("Getting token for user {} from encrypted file at {:?}", user_id, self.credentials_path);
let result = self.get_from_file(user_id);
if result.is_ok() {
log::debug!("Successfully retrieved token from encrypted file");
} else {
log::warn!("Failed to retrieve token from encrypted file: {:?}", result);
}
result
}
}
/// Delete an access token for a user
pub fn delete_token(&self, user_id: &str) -> Result<(), CredentialError> {
if self.using_keyring {
self.delete_from_keyring(user_id)
} else {
self.delete_from_file(user_id)
}
}
// --- Keyring backend ---
fn test_keyring_available() -> bool {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_test_keystore_available()
}
// On Linux, the keyring test can block indefinitely if Secret Service
// (gnome-keyring/kwallet) is unresponsive. Use a timeout to prevent hanging.
#[cfg(target_os = "linux")]
{
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = Self::test_keyring_inner();
let _ = tx.send(result);
});
// Wait up to 2 seconds for keyring response
match rx.recv_timeout(Duration::from_secs(2)) {
Ok(result) => result,
Err(_) => {
log::warn!("Keyring availability check timed out after 2 seconds");
false
}
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
Self::test_keyring_inner()
}
}
#[cfg(target_os = "linux")]
fn test_keyring_inner() -> bool {
// On Linux, test if secret-tool is available
use std::process::Command;
// secret-tool doesn't support --version, so we test with a search command
// that will succeed even if no items are found
match Command::new("secret-tool")
.arg("search")
.arg("service")
.arg("__nonexistent_test__")
.output()
{
Ok(_) => true, // If command runs (even with no results), secret-tool is available
Err(_) => false, // Command not found or can't execute
}
}
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
fn test_keyring_inner() -> bool {
// On macOS/Windows, test using the keyring-rs library
let entry = keyring::Entry::new(SERVICE_NAME, "__test__");
match entry {
Ok(e) => {
// Try to get (will fail with NotFound, which is fine)
// If it fails with a different error, keyring is not available
match e.get_password() {
Ok(_) => true,
Err(keyring::Error::NoEntry) => true,
Err(keyring::Error::NoStorageAccess(_)) => false,
Err(_) => false,
}
}
Err(_) => false,
}
}
fn save_to_keyring(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::save_token(user_id, token)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::{Command, Stdio};
use std::io::Write;
let key = format!("access_token:{}", user_id);
let mut child = Command::new("secret-tool")
.arg("store")
.arg("--label")
.arg(format!("{}@{}", key, SERVICE_NAME))
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| CredentialError::Keyring(format!("Failed to spawn secret-tool: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(token.as_bytes())
.map_err(|e| CredentialError::Keyring(format!("Failed to write to secret-tool: {}", e)))?;
}
let status = child.wait()
.map_err(|e| CredentialError::Keyring(format!("Failed to wait for secret-tool: {}", e)))?;
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry
.set_password(token)
.map_err(|e| CredentialError::Keyring(e.to_string()))
}
}
fn get_from_keyring(&self, user_id: &str) -> Result<String, CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::get_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
log::debug!("Looking up token with service={}, username={}", SERVICE_NAME, key);
let output = Command::new("secret-tool")
.arg("lookup")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.output()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
if output.status.success() {
log::debug!("secret-tool lookup succeeded, token length: {}", output.stdout.len());
let token = String::from_utf8(output.stdout)
.map_err(|e| CredentialError::Keyring(format!("Invalid UTF-8 in token: {}", e)))?
.trim()
.to_string();
Ok(token)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("secret-tool lookup failed with status: {} stderr: {}", output.status, stderr);
Err(CredentialError::NotFound)
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => CredentialError::NotFound,
_ => CredentialError::Keyring(e.to_string()),
})
}
}
fn delete_from_keyring(&self, user_id: &str) -> Result<(), CredentialError> {
// On Android, use Android Keystore via JNI
#[cfg(target_os = "android")]
{
android_keystore::delete_token(user_id)
}
#[cfg(target_os = "linux")]
{
// Use secret-tool directly on Linux as a workaround for keyring-rs library issues
// See Technical Debt section in README.md for details
use std::process::Command;
let key = format!("access_token:{}", user_id);
let status = Command::new("secret-tool")
.arg("clear")
.arg("service")
.arg(SERVICE_NAME)
.arg("username")
.arg(&key)
.status()
.map_err(|e| CredentialError::Keyring(format!("Failed to run secret-tool: {}", e)))?;
// secret-tool clear returns success even if entry doesn't exist
if status.success() {
Ok(())
} else {
Err(CredentialError::Keyring(format!("secret-tool clear failed with status: {}", status)))
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "android")))]
{
let key = format!("access_token:{}", user_id);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| CredentialError::Keyring(e.to_string()))?;
match entry.delete_credential() {
Ok(_) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
Err(e) => Err(CredentialError::Keyring(e.to_string())),
}
}
}
// --- Encrypted file backend ---
fn get_credentials_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
} else {
PathBuf::from(CREDENTIALS_FILENAME)
}
}
fn derive_encryption_key() -> [u8; 32] {
// Derive a key from machine-specific identifiers
// This is less secure than a true keyring but provides some protection
let mut hasher = Sha256::new();
// Use hostname on Linux (where it's available and stable)
#[cfg(target_os = "linux")]
{
if let Ok(hostname) = hostname::get() {
hasher.update(hostname.to_string_lossy().as_bytes());
}
}
// On Android, read device properties from the filesystem
#[cfg(target_os = "android")]
{
// Try to read Android build properties from /system/build.prop
let build_prop_paths = [
"/system/build.prop",
"/vendor/build.prop",
];
for path in &build_prop_paths {
if let Ok(content) = fs::read_to_string(path) {
// Extract key properties for device fingerprint
for line in content.lines() {
if line.starts_with("ro.build.fingerprint=")
|| line.starts_with("ro.serialno=")
|| line.starts_with("ro.build.id=")
|| line.starts_with("ro.product.model=") {
hasher.update(line.as_bytes());
}
}
}
}
// Also use the app data directory path as it's device/install-specific
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
hasher.update(proj_dirs.data_dir().to_string_lossy().as_bytes());
}
}
// Use a static salt (app-specific)
hasher.update(b"jellytau-credential-encryption-v1");
// Add username for additional entropy (if available)
if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
hasher.update(user.as_bytes());
}
hasher.finalize().into()
}
fn load_credentials_file(&self) -> Result<serde_json::Value, CredentialError> {
if !self.credentials_path.exists() {
return Ok(serde_json::json!({}));
}
let encrypted_data =
fs::read_to_string(&self.credentials_path).map_err(|e| CredentialError::Io(e.to_string()))?;
if encrypted_data.is_empty() {
return Ok(serde_json::json!({}));
}
let decrypted = self.decrypt(&encrypted_data)?;
serde_json::from_str(&decrypted).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_credentials_file(&self, data: &serde_json::Value) -> Result<(), CredentialError> {
// Ensure parent directory exists
if let Some(parent) = self.credentials_path.parent() {
fs::create_dir_all(parent).map_err(|e| CredentialError::Io(e.to_string()))?;
}
let json = serde_json::to_string(data).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let encrypted = self.encrypt(&json)?;
fs::write(&self.credentials_path, encrypted).map_err(|e| CredentialError::Io(e.to_string()))
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Prepend nonce to ciphertext and encode as base64
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(BASE64.encode(&combined))
}
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
let combined = BASE64
.decode(encrypted)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption("Invalid encrypted data".to_string()));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(&self.encryption_key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
data[user_id] = serde_json::json!(token);
self.save_credentials_file(&data)
}
fn get_from_file(&self, user_id: &str) -> Result<String, CredentialError> {
let data = self.load_credentials_file()?;
data.get(user_id)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or(CredentialError::NotFound)
}
fn delete_from_file(&self, user_id: &str) -> Result<(), CredentialError> {
let mut data = self.load_credentials_file()?;
if let Some(obj) = data.as_object_mut() {
obj.remove(user_id);
}
self.save_credentials_file(&data)
}
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
// --- Android Keystore integration via JNI ---
#[cfg(target_os = "android")]
mod android_keystore {
use super::*;
use jni::objects::{JClass, JObject, JString, JValue};
use jni::JNIEnv;
use std::sync::OnceLock;
/// Cached reference to the SecureStorage class
static SECURE_STORAGE_CLASS: OnceLock<String> = OnceLock::new();
const SECURE_STORAGE_CLASS_NAME: &str = "com/dtourolle/jellytau/security/SecureStorage";
/// Initialize the SecureStorage singleton from Android context
pub fn initialize_secure_storage(env: &mut JNIEnv, context: &JObject) -> Result<(), String> {
log::info!("Initializing Android SecureStorage...");
// Get the ClassLoader from the Context
let class_loader = env
.call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class
let class_name = env
.new_string(SECURE_STORAGE_CLASS_NAME.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
// Call SecureStorage.initialize(context)
env.call_static_method(
&storage_class,
"initialize",
"(Landroid/content/Context;)V",
&[JValue::Object(context)],
)
.map_err(|e| format!("Failed to initialize SecureStorage: {}", e))?;
// Cache the class name for future use
let _ = SECURE_STORAGE_CLASS.set(SECURE_STORAGE_CLASS_NAME.to_string());
log::info!("Android SecureStorage initialized successfully");
Ok(())
}
/// Test if Android Keystore is available
pub fn test_keystore_available() -> bool {
// Get JNI environment
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
let vm = match vm {
Ok(vm) => vm,
Err(e) => {
log::warn!("Failed to get JavaVM for keystore test: {}", e);
return false;
}
};
let mut env = match vm.attach_current_thread() {
Ok(env) => env,
Err(e) => {
log::warn!("Failed to attach thread for keystore test: {}", e);
return false;
}
};
// Try to get the SecureStorage instance
match get_secure_storage_instance(&mut env) {
Ok(_) => {
log::info!("Android Keystore available via SecureStorage");
true
}
Err(e) => {
log::warn!("Android Keystore not available: {}", e);
false
}
}
}
/// Get the SecureStorage singleton instance
fn get_secure_storage_instance<'a>(env: &mut JNIEnv<'a>) -> Result<JObject<'a>, String> {
let class_name = SECURE_STORAGE_CLASS
.get()
.ok_or_else(|| "SecureStorage not initialized".to_string())?;
// Get the Android context
let ctx = ndk_context::android_context();
let context = unsafe { JObject::from_raw(ctx.context().cast()) };
// Get the ClassLoader from the Context
let class_loader = env
.call_method(&context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])
.map_err(|e| format!("Failed to get ClassLoader: {}", e))?
.l()
.map_err(|e| format!("Failed to convert ClassLoader: {}", e))?;
// Load the SecureStorage class using the app's classloader
let class_name_jstring = env
.new_string(class_name.replace('/', "."))
.map_err(|e| format!("Failed to create class name string: {}", e))?;
let storage_class_obj = env
.call_method(
&class_loader,
"loadClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name_jstring.into())],
)
.map_err(|e| format!("Failed to load SecureStorage class: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to Class: {}", e))?;
let storage_class = JClass::from(storage_class_obj);
let instance = env
.call_static_method(
&storage_class,
"getInstance",
"()Lcom/dtourolle/jellytau/security/SecureStorage;",
&[],
)
.map_err(|e| format!("Failed to get SecureStorage instance: {}", e))?
.l()
.map_err(|e| format!("Failed to convert to object: {}", e))?;
Ok(instance)
}
/// Save a token using Android Keystore
pub fn save_token(user_id: &str, token: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let token_jstring = env
.new_string(token)
.map_err(|e| CredentialError::Keyring(format!("Failed to create token string: {}", e)))?;
let result = env
.call_method(
instance,
"saveToken",
"(Ljava/lang/String;Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into()), JValue::Object(&token_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call saveToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("saveToken returned false".to_string()))
}
}
/// Get a token from Android Keystore
pub fn get_token(user_id: &str) -> Result<String, CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"getToken",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call getToken: {}", e)))?
.l()
.map_err(|e| CredentialError::Keyring(format!("Failed to get object result: {}", e)))?;
if result.is_null() {
return Err(CredentialError::NotFound);
}
let token_jstring = JString::from(result);
let token: String = env
.get_string(&token_jstring)
.map_err(|e| CredentialError::Keyring(format!("Failed to get string: {}", e)))?
.into();
Ok(token)
}
/// Delete a token from Android Keystore
pub fn delete_token(user_id: &str) -> Result<(), CredentialError> {
let ctx = ndk_context::android_context();
let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }
.map_err(|e| CredentialError::Keyring(format!("Failed to get JavaVM: {}", e)))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| CredentialError::Keyring(format!("Failed to attach thread: {}", e)))?;
let instance = get_secure_storage_instance(&mut env)
.map_err(|e| CredentialError::Keyring(e))?;
let key = format!("access_token:{}", user_id);
let key_jstring = env
.new_string(&key)
.map_err(|e| CredentialError::Keyring(format!("Failed to create key string: {}", e)))?;
let result = env
.call_method(
instance,
"deleteToken",
"(Ljava/lang/String;)Z",
&[JValue::Object(&key_jstring.into())],
)
.map_err(|e| CredentialError::Keyring(format!("Failed to call deleteToken: {}", e)))?
.z()
.map_err(|e| CredentialError::Keyring(format!("Failed to get boolean result: {}", e)))?;
if result {
Ok(())
} else {
Err(CredentialError::Keyring("deleteToken returned false".to_string()))
}
}
}
// Export Android keystore functions at the module level for easier access
#[cfg(target_os = "android")]
pub use android_keystore::{initialize_secure_storage, test_keystore_available as android_test_keystore_available};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encryption_roundtrip() {
let store = CredentialStore::new();
let plaintext = "test-access-token-12345";
let encrypted = store.encrypt(plaintext).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(plaintext, decrypted);
}
#[test]
fn test_derive_encryption_key_is_deterministic() {
let key1 = CredentialStore::derive_encryption_key();
let key2 = CredentialStore::derive_encryption_key();
assert_eq!(key1, key2);
}
}

Some files were not shown because too many files have changed in this diff Show More