Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8a8f66e5 | ||
|
|
2e479d05b3 | ||
|
|
1992a8187d | ||
|
|
532ffa661a | ||
|
|
2a1f1689b4 | ||
|
|
a2cd9978f0 | ||
|
|
36be192d44 | ||
|
|
acb7e5f221 | ||
|
|
68c8602230 | ||
|
|
2d141e5bf4 | ||
|
|
c58cc0cf46 | ||
|
|
8938e3fdba | ||
|
|
e2c12615c5 | ||
|
|
0b5a3aa176 | ||
|
|
37455bc470 | ||
|
|
a64e1b1fb4 | ||
|
|
1f6977cd01 | ||
|
|
6af7f7dcca | ||
|
|
75014ee00f | ||
|
|
342f95cac1 | ||
|
|
dcee342c47 | ||
|
|
78f5cd9db9 | ||
|
|
0eae81ec59 | ||
|
|
8eae4ae253 | ||
|
|
ef7be645b3 | ||
|
|
b9249f72e9 | ||
|
|
385d2270c9 | ||
|
|
345bd0730c | ||
|
|
e1e50d51e0 | ||
|
|
7d7f27aa10 | ||
|
|
f1d25c4f4d | ||
|
|
ff8f35084b | ||
|
|
4634ed595c | ||
|
|
6836ce79c8 | ||
|
|
2811e1b7ca | ||
|
|
1836615dc0 | ||
|
|
62874564ff | ||
|
|
17a35573a0 | ||
|
|
dcf08f30bc | ||
|
|
674c8e5cd0 | ||
|
|
45aa029916 | ||
|
|
3faa595b76 | ||
|
|
7fb866a583 | ||
|
|
0c3ed74fe1 | ||
|
|
a5b6266a6d | ||
|
|
a816c84f8c | ||
|
|
87762c03b6 | ||
|
|
975936b902 | ||
|
|
5ba9e0e958 | ||
|
|
ccc9dca924 | ||
|
|
60bb6c72d1 | ||
|
|
37cc9b424d | ||
|
|
2daee7ec2d | ||
|
|
01c6423154 | ||
|
|
d01c2aab9f | ||
|
|
14e9d7e03a | ||
|
|
76c78e2edc | ||
|
|
6146d70bc5 | ||
|
|
ea342d76e3 | ||
|
|
7d07b38d50 | ||
|
|
dc1f516da7 | ||
|
|
731966246f | ||
|
|
57ff364f54 | ||
|
|
ada3ed64ab |
@@ -31,9 +31,9 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -60,12 +60,24 @@ jobs:
|
|||||||
cargo test
|
cargo test
|
||||||
cd ..
|
cd ..
|
||||||
|
|
||||||
build:
|
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
||||||
name: Build Android APK
|
# the full signed release APK is built only on tag pushes by build-release.yml
|
||||||
|
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
||||||
|
# too would duplicate a ~15min build and, without the sync step, produced an
|
||||||
|
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
||||||
|
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
||||||
|
# breakage without linking, bundling, or signing.
|
||||||
|
android-check:
|
||||||
|
name: Android Compile Check
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -78,9 +90,9 @@ jobs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
src-tauri/target
|
src-tauri/target
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-android-
|
||||||
|
|
||||||
- name: Cache Node dependencies
|
- name: Cache Node dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
@@ -93,33 +105,13 @@ jobs:
|
|||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: bun install
|
||||||
bun install
|
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Cargo check (aarch64-linux-android)
|
||||||
run: bun run build
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
|
||||||
run: |
|
run: |
|
||||||
|
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||||
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
||||||
|
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
||||||
|
export AR_aarch64_linux_android="$TC/llvm-ar"
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
echo "" | bunx tauri android init
|
cargo check --target aarch64-linux-android --lib
|
||||||
cd ..
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -18,37 +18,40 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/bin/
|
~/.cargo/registry
|
||||||
~/.cargo/registry/index/
|
~/.cargo/git
|
||||||
~/.cargo/registry/cache/
|
src-tauri/target
|
||||||
~/.cargo/git/db/
|
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Run frontend tests
|
- name: Run frontend tests
|
||||||
run: bun run test --run
|
run: |
|
||||||
|
bunx svelte-kit sync
|
||||||
|
bun run test --run
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|
||||||
- name: Run Rust tests
|
- name: Run Rust tests
|
||||||
@@ -63,45 +66,32 @@ jobs:
|
|||||||
name: Build Linux
|
name: Build Linux
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/bin/
|
~/.cargo/registry
|
||||||
~/.cargo/registry/index/
|
~/.cargo/git
|
||||||
~/.cargo/registry/cache/
|
src-tauri/target
|
||||||
~/.cargo/git/db/
|
key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }}
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-host-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
@@ -135,65 +125,46 @@ jobs:
|
|||||||
name: Build Android
|
name: Build Android
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Cache Rust dependencies
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/bin/
|
~/.cargo/registry
|
||||||
~/.cargo/registry/index/
|
~/.cargo/git
|
||||||
~/.cargo/registry/cache/
|
src-tauri/target
|
||||||
~/.cargo/git/db/
|
|
||||||
target/
|
|
||||||
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-cargo-android-
|
${{ runner.os }}-cargo-android-
|
||||||
|
|
||||||
|
- name: Cache Node dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
node_modules
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Resolve Android NDK path
|
|
||||||
run: echo "ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/25.1.8937393" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set app version from tag
|
- name: Set app version from tag
|
||||||
run: |
|
run: |
|
||||||
REF="${GITHUB_REF#refs/tags/v}"
|
# On a tag build, the tag is the single source of truth for the
|
||||||
VERSION="${REF#refs/heads/}"
|
# version name. On non-tag runs keep whatever is in tauri.conf.json.
|
||||||
# On non-tag runs keep whatever is in tauri.conf.json
|
|
||||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
echo "Setting version to $VERSION"
|
echo "Setting version to $VERSION"
|
||||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||||
fi
|
fi
|
||||||
@@ -201,8 +172,35 @@ jobs:
|
|||||||
|
|
||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
run: bun run tauri android init
|
||||||
env:
|
|
||||||
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
- name: Pin a monotonic Android versionCode
|
||||||
|
run: |
|
||||||
|
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
|
||||||
|
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
|
||||||
|
# number is (a) tiny and (b) NOT monotonic across our history: earlier
|
||||||
|
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
|
||||||
|
# plain 15 would be a *downgrade* and Android would refuse the update.
|
||||||
|
#
|
||||||
|
# Derive an explicit code that is both monotonic in semver order and
|
||||||
|
# always above the 1000 floor already in the field:
|
||||||
|
# code = 1000 + major*10000 + minor*100 + patch
|
||||||
|
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
|
||||||
|
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
|
||||||
|
PROPS="src-tauri/gen/android/app/tauri.properties"
|
||||||
|
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
|
||||||
|
MAJ=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
MIN=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
PAT=$(echo "$VERSION" | cut -d. -f3)
|
||||||
|
# Guard against a malformed/missing component so we never emit code 0.
|
||||||
|
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
|
||||||
|
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
|
||||||
|
echo "version=$VERSION -> versionCode=$CODE"
|
||||||
|
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
|
||||||
|
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
|
||||||
|
else
|
||||||
|
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
cat "$PROPS"
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
@@ -218,11 +216,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build signed Android APK
|
- name: Build signed Android APK
|
||||||
run: bun run tauri android build --apk
|
run: bun run tauri android build --apk true --target aarch64
|
||||||
env:
|
|
||||||
ANDROID_NDK_HOME: ${{ env.ANDROID_NDK_HOME }}
|
|
||||||
ANDROID_SDK_ROOT: ${{ env.ANDROID_SDK_ROOT }}
|
|
||||||
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
|
|
||||||
|
|
||||||
- name: Collect & verify signed APK
|
- name: Collect & verify signed APK
|
||||||
run: |
|
run: |
|
||||||
@@ -247,6 +241,8 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: [build-linux, build-android]
|
needs: [build-linux, build-android]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
container:
|
||||||
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -273,23 +269,23 @@ jobs:
|
|||||||
id: release_notes
|
id: release_notes
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||||
echo "## 📱 JellyTau $VERSION Release" > release_notes.md
|
echo "## JellyTau $VERSION Release" > release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "### 📦 Downloads" >> release_notes.md
|
echo "### Downloads" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Linux" >> release_notes.md
|
echo "#### Linux" >> release_notes.md
|
||||||
echo "- **AppImage** - Run directly on most Linux distributions" >> 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 "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Android" >> 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 "- **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 "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "### ✨ What's New" >> release_notes.md
|
echo "### What's New" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
echo "See [CHANGELOG.md](CHANGELOG.md) for detailed changes." >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "### 🔧 Installation" >> release_notes.md
|
echo "### Installation" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "#### Linux (AppImage)" >> release_notes.md
|
echo "#### Linux (AppImage)" >> release_notes.md
|
||||||
echo "\`\`\`bash" >> release_notes.md
|
echo "\`\`\`bash" >> release_notes.md
|
||||||
@@ -307,11 +303,11 @@ jobs:
|
|||||||
echo "- Sideload: Download APK and install via file manager or ADB" >> 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 "- Play Store: Coming soon" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "### 🐛 Known Issues" >> release_notes.md
|
echo "### Known Issues" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
|
echo "See [GitHub Issues](../../issues) for reported bugs." >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "### 📝 Requirements" >> release_notes.md
|
echo "### Requirements" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "**Linux:**" >> release_notes.md
|
echo "**Linux:**" >> release_notes.md
|
||||||
echo "- 64-bit Linux system" >> release_notes.md
|
echo "- 64-bit Linux system" >> release_notes.md
|
||||||
@@ -322,7 +318,7 @@ jobs:
|
|||||||
echo "- 50MB free storage" >> release_notes.md
|
echo "- 50MB free storage" >> release_notes.md
|
||||||
echo "" >> release_notes.md
|
echo "" >> release_notes.md
|
||||||
echo "---" >> release_notes.md
|
echo "---" >> release_notes.md
|
||||||
echo "Built with Tauri, SvelteKit, and Rust 🦀" >> release_notes.md
|
echo "Built with Tauri, SvelteKit, and Rust" >> release_notes.md
|
||||||
|
|
||||||
- name: Publish Gitea release & upload assets
|
- name: Publish Gitea release & upload assets
|
||||||
env:
|
env:
|
||||||
@@ -346,11 +342,20 @@ jobs:
|
|||||||
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
'{tag_name:$tag, name:$name, body:$body, draft:false, prerelease:$pre}')
|
||||||
|
|
||||||
echo "📦 Creating release $VERSION on $REPO"
|
echo "📦 Creating release $VERSION on $REPO"
|
||||||
RESP=$(curl -fsS -X POST "$API/repos/$REPO/releases" \
|
# -f drops on HTTP error; capture status so an existing release (409) is handled gracefully.
|
||||||
|
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/repos/$REPO/releases" \
|
||||||
-H "Authorization: token $TOKEN" \
|
-H "Authorization: token $TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$PAYLOAD")
|
-d "$PAYLOAD")
|
||||||
RELEASE_ID=$(echo "$RESP" | jq -r '.id')
|
if [ "$HTTP" = "201" ]; then
|
||||||
|
RELEASE_ID=$(jq -r '.id' resp.json)
|
||||||
|
elif [ "$HTTP" = "409" ]; then
|
||||||
|
echo "ℹ️ Release $VERSION already exists; fetching its id to upload assets"
|
||||||
|
RELEASE_ID=$(curl -fsS "$API/repos/$REPO/releases/tags/$VERSION" \
|
||||||
|
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||||
|
else
|
||||||
|
echo "❌ Failed to create release (HTTP $HTTP):"; cat resp.json; exit 1
|
||||||
|
fi
|
||||||
echo "Release id=$RELEASE_ID"
|
echo "Release id=$RELEASE_ID"
|
||||||
|
|
||||||
for f in artifacts/android/* artifacts/linux/*; do
|
for f in artifacts/android/* artifacts/linux/*; do
|
||||||
|
|||||||
@@ -95,20 +95,28 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check each file
|
# Check each file
|
||||||
MISSING_TRACES=0
|
# Pipe into the loop instead of a here-string (<<<) so this step works
|
||||||
while IFS= read -r file; do
|
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
|
||||||
|
# for the same reason. The loop runs in a subshell (so a counter var
|
||||||
|
# wouldn't survive), so we record warnings in a temp file and count it
|
||||||
|
# afterwards.
|
||||||
|
MISSING_FILE=$(mktemp)
|
||||||
|
echo "$CHANGED" | while IFS= read -r file; do
|
||||||
# Skip test files
|
# Skip test files
|
||||||
if [[ "$file" == *".test."* ]]; then
|
case "$file" in
|
||||||
continue
|
*.test.*) continue ;;
|
||||||
fi
|
esac
|
||||||
|
|
||||||
if [ -f "$file" ]; then
|
if [ -f "$file" ]; then
|
||||||
if ! grep -q "TRACES:" "$file"; then
|
if ! grep -q "TRACES:" "$file"; then
|
||||||
echo "⚠️ Missing TRACES: $file"
|
echo "⚠️ Missing TRACES: $file"
|
||||||
MISSING_TRACES=$((MISSING_TRACES + 1))
|
echo "$file" >> "$MISSING_FILE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done <<< "$CHANGED"
|
done
|
||||||
|
|
||||||
|
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
|
||||||
|
rm -f "$MISSING_FILE"
|
||||||
|
|
||||||
if [ "$MISSING_TRACES" -gt 0 ]; then
|
if [ "$MISSING_TRACES" -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Use bun (see packageManager in package.json); ignore other package managers' lockfiles
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/build
|
/build
|
||||||
/dist
|
/dist
|
||||||
@@ -50,3 +55,6 @@ logs
|
|||||||
|
|
||||||
# Android signing keystore (NEVER commit)
|
# Android signing keystore (NEVER commit)
|
||||||
android-keystore/
|
android-keystore/
|
||||||
|
|
||||||
|
# Local machine-specific Android NDK toolchain paths (do not commit)
|
||||||
|
src-tauri/.cargo/config.toml
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ FROM ubuntu:24.04
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
ANDROID_HOME=/opt/android-sdk \
|
ANDROID_HOME=/opt/android-sdk \
|
||||||
NDK_VERSION=27.0.11902837 \
|
NDK_VERSION=27.0.11902837 \
|
||||||
SDK_VERSION=34 \
|
SDK_VERSION=36 \
|
||||||
|
BUILD_TOOLS_VERSION=35.0.0 \
|
||||||
RUST_BACKTRACE=1 \
|
RUST_BACKTRACE=1 \
|
||||||
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
PATH="/root/.bun/bin:/root/.cargo/bin:$PATH" \
|
||||||
CARGO_HOME=/root/.cargo
|
CARGO_HOME=/root/.cargo
|
||||||
@@ -20,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
unzip \
|
unzip \
|
||||||
|
jq \
|
||||||
openjdk-17-jdk-headless \
|
openjdk-17-jdk-headless \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
@@ -67,10 +69,14 @@ RUN wget -q https://dl.google.com/android/repository/commandlinetools-linux-1107
|
|||||||
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
mkdir -p $ANDROID_HOME/cmdline-tools/latest && \
|
||||||
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
mv $ANDROID_HOME/cmdline-tools/* $ANDROID_HOME/cmdline-tools/latest/ 2>/dev/null || true
|
||||||
|
|
||||||
# Install Android SDK components
|
# Accept all SDK licenses up front so Gradle can install/use components non-interactively
|
||||||
|
RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME --licenses > /dev/null
|
||||||
|
|
||||||
|
# Install Android SDK components (must match the compileSdk/targetSdk in the generated Gradle project)
|
||||||
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||||
|
"platform-tools" \
|
||||||
"platforms;android-$SDK_VERSION" \
|
"platforms;android-$SDK_VERSION" \
|
||||||
"build-tools;34.0.0" \
|
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||||
"ndk;$NDK_VERSION" \
|
"ndk;$NDK_VERSION" \
|
||||||
--channel=0 2>&1 | grep -v "Warning" || true
|
--channel=0 2>&1 | grep -v "Warning" || true
|
||||||
|
|
||||||
|
|||||||
@@ -1,336 +1,29 @@
|
|||||||
# JellyTau
|
<h1 align="center">
|
||||||
|
<img src="docs/assets/logo.png" alt="JellyTau logo" width="120" /><br />
|
||||||
|
JellyTau
|
||||||
|
</h1>
|
||||||
|
|
||||||
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
A cross-platform Jellyfin client built with Tauri, SvelteKit, and TypeScript.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
Business logic lives in a Rust backend; a UI-rich Svelte frontend handles
|
||||||
|
presentation and talks to it over Tauri's IPC. Targets Linux (libmpv) and
|
||||||
|
Android (ExoPlayer).
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
## Getting Started
|
||||||
|
|
||||||
---
|
This project uses [bun](https://bun.sh) as its package manager.
|
||||||
|
|
||||||
# Requirements Specification
|
|
||||||
|
|
||||||
## 1. User Requirements
|
|
||||||
|
|
||||||
| ID | Requirement | Priority | Status |
|
|
||||||
|----|-------------|----------|--------|
|
|
||||||
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
|
||||||
| UR-002 | Access media when online or offline | High | Done |
|
|
||||||
| UR-003 | Play videos | High | Done |
|
|
||||||
| UR-004 | Play audio uninterrupted | High | Done |
|
|
||||||
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
|
||||||
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
|
||||||
| UR-007 | Navigate media in library | High | Done |
|
|
||||||
| UR-008 | Search media across libraries | High | Done |
|
|
||||||
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
|
||||||
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
|
||||||
| UR-011 | Download media on demand | Medium | Done |
|
|
||||||
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
|
||||||
| UR-013 | View and manage downloaded media | Medium | Done |
|
|
||||||
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
|
|
||||||
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
|
||||||
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
|
||||||
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
|
||||||
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
|
||||||
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
|
||||||
| UR-020 | Select subtitles for video content | High | Done |
|
|
||||||
| UR-021 | Select audio track for video content | High | Done |
|
|
||||||
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
|
||||||
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
|
||||||
| UR-024 | View recently added content on server | Medium | Done |
|
|
||||||
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
|
||||||
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
|
||||||
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
|
||||||
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
|
||||||
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
|
||||||
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
|
||||||
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
|
||||||
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
|
||||||
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
|
||||||
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
|
||||||
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
|
||||||
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
|
||||||
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
|
||||||
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
|
||||||
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Software Requirements
|
|
||||||
|
|
||||||
### 2.1 Integration Requirements
|
|
||||||
|
|
||||||
External system integrations and platform-specific implementations.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
|
||||||
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
|
||||||
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
|
||||||
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
|
||||||
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
|
||||||
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
|
||||||
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
|
||||||
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
|
||||||
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
|
||||||
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
|
||||||
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
|
||||||
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
|
||||||
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
|
||||||
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
|
||||||
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
|
||||||
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
|
||||||
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
|
||||||
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
|
||||||
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
|
||||||
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
|
||||||
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
|
||||||
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
|
||||||
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
|
||||||
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
|
||||||
|
|
||||||
### 2.2 Jellyfin API Requirements
|
|
||||||
|
|
||||||
API endpoints and data contracts required for Jellyfin integration.
|
|
||||||
|
|
||||||
| ID | Requirement | Endpoint Category | Traces To | Status |
|
|
||||||
|----|-------------|-------------------|-----------|--------|
|
|
||||||
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
|
||||||
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
|
||||||
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
|
||||||
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
|
||||||
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
|
||||||
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
|
||||||
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
|
||||||
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
|
||||||
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
|
||||||
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
|
||||||
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
|
||||||
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
|
||||||
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
|
||||||
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
|
||||||
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
|
||||||
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
|
||||||
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
|
||||||
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
|
||||||
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
|
|
||||||
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
|
|
||||||
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
|
||||||
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
|
||||||
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
|
||||||
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
|
||||||
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
|
||||||
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
|
||||||
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
|
||||||
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
|
||||||
|
|
||||||
### 2.3 Development Requirements
|
|
||||||
|
|
||||||
Internal architecture, components, and application logic.
|
|
||||||
|
|
||||||
| ID | Requirement | Category | Traces To | Status |
|
|
||||||
|----|-------------|----------|-----------|--------|
|
|
||||||
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
|
||||||
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
|
||||||
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
|
||||||
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
|
||||||
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
|
||||||
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
|
||||||
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
|
||||||
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
|
||||||
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
|
||||||
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
|
||||||
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
|
||||||
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
|
||||||
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
|
|
||||||
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
|
||||||
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
|
||||||
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
|
||||||
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
|
||||||
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
|
|
||||||
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
|
||||||
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
|
||||||
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
|
||||||
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
|
||||||
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
|
||||||
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
|
||||||
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
|
||||||
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
|
||||||
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
|
||||||
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
|
||||||
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
|
||||||
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
|
||||||
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
|
||||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
|
||||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
|
||||||
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
|
||||||
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
|
||||||
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
|
||||||
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
|
||||||
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
|
||||||
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
|
||||||
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
|
||||||
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
|
||||||
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
|
||||||
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
|
||||||
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
|
||||||
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
|
||||||
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
|
||||||
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
|
||||||
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
|
||||||
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Traceability Matrix
|
|
||||||
|
|
||||||
### User Requirements to Software Requirements
|
|
||||||
|
|
||||||
| User Req | Integration Requirements | Development Requirements |
|
|
||||||
|----------|-------------------------|-------------------------|
|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
|
||||||
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
|
||||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
|
||||||
| UR-005 | - | DR-001, DR-005, DR-009 |
|
|
||||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
|
||||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
|
||||||
| UR-008 | IR-010 | DR-007, DR-011 |
|
|
||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
|
||||||
| UR-010 | IR-012, IR-021 | DR-037 |
|
|
||||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
|
||||||
| UR-012 | IR-009, IR-014 | - |
|
|
||||||
| UR-013 | IR-013 | DR-017 |
|
|
||||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
|
||||||
| UR-015 | - | DR-005, DR-020 |
|
|
||||||
| UR-016 | - | - |
|
|
||||||
| UR-017 | - | DR-014, DR-021 |
|
|
||||||
| UR-018 | IR-013 | DR-015, DR-018 |
|
|
||||||
| UR-019 | IR-015 | DR-022 |
|
|
||||||
| UR-020 | IR-016, IR-018 | DR-023 |
|
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 |
|
|
||||||
| UR-022 | IR-017 | DR-025 |
|
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
|
||||||
| UR-024 | IR-010 | DR-027 |
|
|
||||||
| UR-025 | IR-015 | DR-028 |
|
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
|
||||||
| UR-027 | IR-020 | DR-030 |
|
|
||||||
| UR-028 | - | DR-031 |
|
|
||||||
| UR-029 | - | DR-032 |
|
|
||||||
| UR-030 | IR-010 | DR-033 |
|
|
||||||
| UR-031 | - | DR-034 |
|
|
||||||
| UR-032 | - | DR-035 |
|
|
||||||
| UR-033 | - | DR-036 |
|
|
||||||
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
|
||||||
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
|
||||||
| UR-036 | IR-022, IR-023 | DR-041 |
|
|
||||||
| UR-037 | IR-010 | DR-042 |
|
|
||||||
| UR-038 | IR-010 | DR-043 |
|
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Test Traceability
|
|
||||||
|
|
||||||
### Unit Tests to Software Requirements
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| UT-001 | Player state transitions | DR-001 | Pending |
|
|
||||||
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
|
||||||
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
|
||||||
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
|
||||||
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
|
||||||
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
|
||||||
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
|
||||||
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
|
||||||
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
|
||||||
| UT-010 | Download queue management | DR-015 | Done |
|
|
||||||
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
|
||||||
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
|
||||||
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
|
||||||
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
|
||||||
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
|
||||||
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
|
||||||
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
|
||||||
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
|
||||||
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
|
||||||
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
|
||||||
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
|
||||||
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
|
||||||
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
|
||||||
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
|
||||||
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
|
||||||
| UT-027 | NullBackend set volume | DR-004 | Done |
|
|
||||||
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
|
||||||
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
|
||||||
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
|
||||||
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
|
||||||
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
|
||||||
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
|
||||||
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
|
||||||
| UT-035 | Download event completed serialization | DR-015 | Done |
|
|
||||||
| UT-036 | Download event failed serialization | DR-015 | Done |
|
|
||||||
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
|
||||||
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
|
||||||
| UT-039 | Download manager creation | DR-015 | Done |
|
|
||||||
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
|
||||||
| UT-041 | Download info serialization | DR-015 | Done |
|
|
||||||
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
|
||||||
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
|
||||||
| UT-044 | Offline item serialization | DR-017 | Done |
|
|
||||||
| UT-045 | Smart cache default config | DR-015 | Done |
|
|
||||||
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
|
||||||
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
|
||||||
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
|
||||||
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
|
|
||||||
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
|
|
||||||
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
|
||||||
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
|
||||||
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
|
||||||
|---------|-----------------|-----------|--------|
|
|
||||||
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
|
||||||
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
|
||||||
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
|
||||||
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
|
||||||
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
|
||||||
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
|
||||||
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
|
||||||
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
|
||||||
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
|
||||||
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
|
||||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
|
||||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Development Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Activate Rust environment (fish shell)
|
# Activate the Rust environment (fish shell)
|
||||||
source "$HOME/.cargo/env.fish"
|
source "$HOME/.cargo/env.fish"
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Development
|
# Run in development
|
||||||
bun run tauri dev
|
bun run tauri dev
|
||||||
|
|
||||||
# Type checking
|
# Type-check the frontend
|
||||||
bun run check
|
bun run check
|
||||||
|
|
||||||
# Build for Linux
|
# Build for Linux
|
||||||
@@ -340,168 +33,28 @@ bun run tauri build
|
|||||||
bun run tauri android build
|
bun run tauri android build
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
For the full set of build, test, and Android helper scripts, see
|
||||||
|
[scripts/README.md](scripts/README.md).
|
||||||
|
|
||||||
## 6. Architecture Overview
|
## Documentation
|
||||||
|
|
||||||
```
|
| Topic | Location |
|
||||||
jellytau/
|
|-------|----------|
|
||||||
├── src/ # Svelte frontend
|
| Architecture overview & subsystem docs | [docs/architecture/](docs/architecture/) |
|
||||||
│ ├── lib/
|
| Requirements, traceability & technical debt | [docs/requirements.md](docs/requirements.md) |
|
||||||
│ │ ├── api/ # Jellyfin API client (repository pattern)
|
| Build & release process | [docs/build-release.md](docs/build-release.md) |
|
||||||
│ │ ├── components/ # UI components (player, library)
|
| Docker builds | [docs/build/docker.md](docs/build/docker.md) |
|
||||||
│ │ └── stores/ # Svelte stores (auth, library, player, queue)
|
| Traceability tooling & CI | [docs/traceability.md](docs/traceability.md), [docs/traceability-ci.md](docs/traceability-ci.md) |
|
||||||
│ └── routes/ # SvelteKit pages
|
| Release checklist | [docs/release-checklist.md](docs/release-checklist.md) |
|
||||||
├── src-tauri/ # Rust backend
|
| UX flows | [docs/ux-flows.md](docs/ux-flows.md) |
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── commands/ # Tauri commands
|
|
||||||
│ │ └── player/ # Player architecture
|
|
||||||
│ │ ├── state.rs # State machine
|
|
||||||
│ │ ├── media.rs # MediaItem, MediaSource
|
|
||||||
│ │ ├── queue.rs # Queue management
|
|
||||||
│ │ └── backend.rs # PlayerBackend trait
|
|
||||||
│ └── gen/android/ # Android project
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Recommended IDE Setup
|
||||||
|
|
||||||
## 7. Technical Debt
|
[VS Code](https://code.visualstudio.com/) +
|
||||||
|
[Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) +
|
||||||
|
[Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) +
|
||||||
|
[rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||||
|
|
||||||
### Linux Keyring Integration Workaround
|
## License
|
||||||
|
|
||||||
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
MIT
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
|
||||||
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
|
||||||
- Session restoration fails on app restart even though credentials exist
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
|
||||||
|
|
||||||
**Current Workaround**:
|
|
||||||
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
|
||||||
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
|
||||||
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
|
||||||
- **Delete**: `secret-tool clear service <service> username <username>`
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
See [src-tauri/src/credentials.rs](src-tauri/src/credentials.rs):
|
|
||||||
- Lines 165-209: `save_to_keyring()` - Uses `secret-tool store` on Linux
|
|
||||||
- Lines 214-248: `get_from_keyring()` - Uses `secret-tool lookup` on Linux
|
|
||||||
- Lines 254-286: `delete_from_keyring()` - Uses `secret-tool clear` on Linux
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
- Monitor `keyring-rs` for bug fixes in future versions
|
|
||||||
- Consider alternative secure storage libraries
|
|
||||||
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
|
||||||
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - The workaround is functionally equivalent to proper keyring integration
|
|
||||||
- Credentials are stored securely in the system keyring
|
|
||||||
- Session restoration works correctly
|
|
||||||
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
|
||||||
|
|
||||||
**Dependencies**:
|
|
||||||
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
|
||||||
- Already available on most Linux distributions by default
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Platform Playback Backend Parity (Linux vs Android)
|
|
||||||
|
|
||||||
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
|
||||||
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
|
||||||
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
|
||||||
|
|
||||||
**Root Cause**:
|
|
||||||
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src-tauri/src/player/backend.rs:95-103](src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
|
||||||
- [src-tauri/src/player/mpv/mod.rs](src-tauri/src/player/mpv/mod.rs) - Full audio settings support
|
|
||||||
- [src-tauri/src/player/android/mod.rs](src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
|
||||||
|
|
||||||
**Feature Parity Matrix**:
|
|
||||||
|
|
||||||
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
|
||||||
|---------|-------------|---------------------|--------|
|
|
||||||
| Basic playback | ✅ | ✅ | Parity |
|
|
||||||
| Volume control | ✅ | ✅ | Parity |
|
|
||||||
| Seek | ✅ | ✅ | Parity |
|
|
||||||
| Crossfade | ✅ | ❌ | Gap |
|
|
||||||
| Gapless playback | ✅ | ❌ | Gap |
|
|
||||||
| Volume normalization | ✅ | ❌ | Gap |
|
|
||||||
| Position updates | 250ms | On-demand | Inconsistent |
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
|
||||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
|
||||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
|
||||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
|
||||||
5. Standardize position update frequency across platforms
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
|
||||||
- User experience differs between platforms
|
|
||||||
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
|
||||||
|
|
||||||
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend Playback Code Duplication
|
|
||||||
|
|
||||||
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
|
||||||
|
|
||||||
**Symptoms**:
|
|
||||||
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
|
||||||
- Same `$derived` state merging logic for local/remote playback in both components
|
|
||||||
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
|
||||||
|
|
||||||
**Affected Files**:
|
|
||||||
- [src/lib/components/player/AudioPlayer.svelte:69-140](src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/components/player/MiniPlayer.svelte:74-122](src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
|
||||||
- [src/lib/services/playbackControl.ts:88](src/lib/services/playbackControl.ts) - Position conversion
|
|
||||||
- [src/lib/stores/playbackMode.ts](src/lib/stores/playbackMode.ts) - Position conversion
|
|
||||||
- [src/lib/services/playbackReporting.ts](src/lib/services/playbackReporting.ts) - Position conversion
|
|
||||||
|
|
||||||
**Duplicated Code**:
|
|
||||||
```typescript
|
|
||||||
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
|
||||||
handlePlayPause(), handleNext(), handlePrevious(),
|
|
||||||
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
|
||||||
|
|
||||||
// These derived states use identical logic:
|
|
||||||
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Future Fix**:
|
|
||||||
1. Create `src/lib/utils/playbackUnits.ts`:
|
|
||||||
```typescript
|
|
||||||
export const TICKS_PER_SECOND = 10_000_000;
|
|
||||||
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
|
||||||
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
|
||||||
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
|
||||||
- Single source of truth for merged local/remote state
|
|
||||||
|
|
||||||
3. Simplify handler wrappers using a utility:
|
|
||||||
```typescript
|
|
||||||
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
|
||||||
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
|
||||||
```
|
|
||||||
|
|
||||||
**Impact**:
|
|
||||||
- Low - Code works correctly but violates DRY principle
|
|
||||||
- Maintenance burden when logic needs to change
|
|
||||||
- Risk of handlers diverging over time
|
|
||||||
|
|
||||||
**Traces To**: DR-009
|
|
||||||
|
|||||||
@@ -176,7 +176,10 @@ classDiagram
|
|||||||
-server_url: String
|
-server_url: String
|
||||||
-user_id: String
|
-user_id: String
|
||||||
-access_token: String
|
-access_token: String
|
||||||
|
-connectivity: Option~Arc~ConnectivityMonitor~~
|
||||||
+new()
|
+new()
|
||||||
|
+with_connectivity()
|
||||||
|
-report_outcome()
|
||||||
}
|
}
|
||||||
|
|
||||||
class OfflineRepository {
|
class OfflineRepository {
|
||||||
@@ -192,10 +195,9 @@ classDiagram
|
|||||||
class HybridRepository {
|
class HybridRepository {
|
||||||
-online: Arc~OnlineRepository~
|
-online: Arc~OnlineRepository~
|
||||||
-offline: Arc~OfflineRepository~
|
-offline: Arc~OfflineRepository~
|
||||||
-connectivity: Arc~ConnectivityMonitor~
|
|
||||||
+new()
|
+new()
|
||||||
-parallel_query()
|
-parallel_race()
|
||||||
-has_meaningful_content()
|
-cache_with_timeout()
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaRepository <|.. OnlineRepository
|
MediaRepository <|.. OnlineRepository
|
||||||
@@ -214,6 +216,7 @@ classDiagram
|
|||||||
- Returns cache result if it has meaningful content
|
- Returns cache result if it has meaningful content
|
||||||
- Falls back to server result otherwise
|
- Falls back to server result otherwise
|
||||||
- Background cache updates planned
|
- Background cache updates planned
|
||||||
|
- **Connectivity feedback**: `OnlineRepository` reports the outcome of every server request to the `ConnectivityMonitor` (classified via `RepoError`). This is the source of truth for the offline/online banner — see [07-connectivity.md](07-connectivity.md). The frontend `connectivity` store is a pure reflection of the resulting events; `navigator.onLine` is only an advisory hint that triggers an immediate recheck.
|
||||||
|
|
||||||
2. **Handle-Based Resource Management** (`repository.rs` commands):
|
2. **Handle-Based Resource Management** (`repository.rs` commands):
|
||||||
```rust
|
```rust
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ sequenceDiagram
|
|||||||
participant Hybrid as HybridRepository
|
participant Hybrid as HybridRepository
|
||||||
participant Cache as OfflineRepository (SQLite)
|
participant Cache as OfflineRepository (SQLite)
|
||||||
participant Server as OnlineRepository (HTTP)
|
participant Server as OnlineRepository (HTTP)
|
||||||
|
participant Conn as ConnectivityMonitor
|
||||||
|
|
||||||
UI->>Client: getItems(parentId)
|
UI->>Client: getItems(parentId)
|
||||||
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
Client->>Rust: invoke("repository_get_items", {handle, parentId})
|
||||||
@@ -20,6 +21,13 @@ sequenceDiagram
|
|||||||
Hybrid->>Server: get_items() (no timeout)
|
Hybrid->>Server: get_items() (no timeout)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Note over Server,Conn: Every server request reports its outcome
|
||||||
|
alt Server succeeds (or answers with 4xx/5xx)
|
||||||
|
Server->>Conn: mark_reachable() (server is up)
|
||||||
|
else Network failure / timeout
|
||||||
|
Server->>Conn: mark_unreachable() (debounced)
|
||||||
|
end
|
||||||
|
|
||||||
alt Cache returns with content
|
alt Cache returns with content
|
||||||
Cache-->>Hybrid: Result with items
|
Cache-->>Hybrid: Result with items
|
||||||
Hybrid-->>Rust: Return cache result
|
Hybrid-->>Rust: Return cache result
|
||||||
@@ -39,6 +47,7 @@ sequenceDiagram
|
|||||||
- Cache wins if it has meaningful content
|
- Cache wins if it has meaningful content
|
||||||
- Automatic fallback to server if cache is empty/stale
|
- Automatic fallback to server if cache is empty/stale
|
||||||
- Background cache updates (planned)
|
- Background cache updates (planned)
|
||||||
|
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
||||||
|
|
||||||
## Playback Initiation Flow
|
## Playback Initiation Flow
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,50 @@ flowchart LR
|
|||||||
|
|
||||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||||
|
|
||||||
|
## HTML5 Video Adapter (webview-rendered video)
|
||||||
|
|
||||||
|
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||||
|
`src-tauri/src/commands/player/timers.rs`
|
||||||
|
|
||||||
|
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||||
|
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||||
|
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||||
|
the real player, living outside Rust's reach.
|
||||||
|
|
||||||
|
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
||||||
|
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
||||||
|
authority:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Webview["Webview"]
|
||||||
|
Video["HTML5 <video> / HLS.js"]
|
||||||
|
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||||||
|
end
|
||||||
|
subgraph Backend["Rust"]
|
||||||
|
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||||||
|
Controller["PlayerController"]
|
||||||
|
Emitter["TauriEventEmitter"]
|
||||||
|
end
|
||||||
|
subgraph Frontend["Frontend"]
|
||||||
|
Events["playerEvents.ts"]
|
||||||
|
Store["player store"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
||||||
|
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
||||||
|
another event source feeding the existing pipeline.
|
||||||
|
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||||||
|
60fps RAF loop.
|
||||||
|
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
||||||
|
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
||||||
|
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
||||||
|
("frontend only displays state and invokes commands") for the video path.
|
||||||
|
|
||||||
## MpvBackend (Linux)
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
**Location**: `src-tauri/src/player/mpv/`
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ pub struct HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct HttpConfig {
|
pub struct HttpConfig {
|
||||||
pub base_url: String,
|
pub timeout: Duration, // Default: 30s (large library queries can be slow)
|
||||||
pub timeout: Duration, // Default: 10s
|
|
||||||
pub max_retries: u32, // Default: 3
|
pub max_retries: u32, // Default: 3
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Note: ordinary requests use the 30s timeout above. The connectivity recovery probe (`ping`) uses a shorter, dedicated 5s timeout so an unreachable server is detected quickly while offline.
|
||||||
|
|
||||||
**Retry Strategy:**
|
**Retry Strategy:**
|
||||||
- Retry delays: 1s, 2s, 4s (exponential backoff)
|
- Retry delays: 1s, 2s, 4s (exponential backoff)
|
||||||
- Retries on: Network errors, 5xx server errors
|
- Retries on: Network errors, 5xx server errors
|
||||||
@@ -38,39 +39,71 @@ pub enum ErrorKind {
|
|||||||
|
|
||||||
**Location**: `src-tauri/src/connectivity/mod.rs`
|
**Location**: `src-tauri/src/connectivity/mod.rs`
|
||||||
|
|
||||||
The connectivity monitor tracks server reachability with adaptive polling:
|
The connectivity monitor is the **single source of truth** for server reachability. Its primary signal is the outcome of *real repository traffic* — every server request the user actually makes. A standalone `/System/Info/Public` probe is kept only as an offline recovery detector.
|
||||||
|
|
||||||
|
### Source of truth: repository traffic
|
||||||
|
|
||||||
|
`OnlineRepository` reports the result of each server request to the monitor, classified via `RepoError`:
|
||||||
|
|
||||||
|
| Repository outcome | Meaning | Effect on reachability |
|
||||||
|
|--------------------|---------|------------------------|
|
||||||
|
| `Ok(_)` | Server answered successfully | Mark **reachable** (instant recovery) |
|
||||||
|
| `Err(Authentication)` | Server answered with 401/403 | Mark **reachable** (server is up; request was rejected) |
|
||||||
|
| `Err(NotFound)` | Server answered with 404 | Mark **reachable** (server is up) |
|
||||||
|
| `Err(Server)` | Server answered with 5xx / bad body | Mark **reachable** (server is up) |
|
||||||
|
| `Err(Network)` | Connection failure / timeout / DNS | **Candidate for offline** (see debounce) |
|
||||||
|
| `Err(Database)` | Local cache error only | No effect (not a server signal) |
|
||||||
|
|
||||||
|
This classification fixes the previous bug where a successful `/System/Info/Public` ping reported "online" even while the user's authenticated data calls were failing — and vice versa.
|
||||||
|
|
||||||
|
### Time-window debounce (offline) + instant recovery (online)
|
||||||
|
|
||||||
|
To stop the banner from flapping on a single dropped request, the transition to **offline** is debounced over a time window:
|
||||||
|
|
||||||
|
- On the **first** `Network` failure, the monitor records `first_failure_at`.
|
||||||
|
- It flips `is_server_reachable = false` only once `Network` failures have persisted continuously for `OFFLINE_CONFIRM_WINDOW` (5s) with no intervening success.
|
||||||
|
- **Any** success (or server-answered error) clears `first_failure_at` and immediately marks reachable.
|
||||||
|
|
||||||
|
Recovery is therefore instant and asymmetric: one good response brings the app back online, but a brief blip never trips the banner.
|
||||||
|
|
||||||
|
### Offline-only recovery probe
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
Monitor["ConnectivityMonitor"] --> Poller["Background Task"]
|
Repo["OnlineRepository"] -->|"success / RepoError"| Monitor["ConnectivityMonitor"]
|
||||||
Poller --> Check{"Server<br/>Reachable?"}
|
Monitor --> State{"is_server_reachable?"}
|
||||||
Check -->|"Yes"| Online["30s Interval"]
|
State -->|"Online"| NoProbe["No background polling<br/>(real traffic is the signal)"]
|
||||||
Check -->|"No"| Offline["5s Interval"]
|
State -->|"Offline"| Probe["5s /System/Info/Public probe<br/>(recovery detector)"]
|
||||||
Online --> Emit["Emit Events"]
|
Probe -->|"reachable again"| Monitor
|
||||||
Offline --> Emit
|
Monitor -->|"on change"| Emit["Emit connectivity:changed<br/>+ connectivity:reconnected"]
|
||||||
Emit --> Frontend["Frontend Store"]
|
Emit --> Frontend["Frontend Store → banner"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
While **online**, there is no background polling — real requests keep the state fresh. While **offline**, the fast 5s probe runs so an idle app still detects the server returning even when no user traffic is flowing.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
- **Adaptive Polling**: 30s when online, 5s when offline (for quick reconnection detection)
|
- **Traffic-driven**: Reachability follows the requests the user actually makes.
|
||||||
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events
|
- **Time-window debounce**: Offline declared only after `OFFLINE_CONFIRM_WINDOW` (5s) of sustained network failure; recovery is instant.
|
||||||
- **Manual Marking**: Can mark reachable/unreachable based on API call results
|
- **Offline-only probe**: 5s `/System/Info/Public` probe runs only while offline.
|
||||||
- **Thread-Safe**: Uses Arc<RwLock<>> for shared state
|
- **Event Emission**: Emits `connectivity:changed` and `connectivity:reconnected` events.
|
||||||
|
- **Thread-Safe**: Uses `Arc<RwLock<>>` for shared state.
|
||||||
|
|
||||||
**Tauri Commands:**
|
**Tauri Commands:**
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `connectivity_check_server` | Manual reachability check |
|
| `connectivity_check_server` | Manual reachability check (also used by the frontend's advisory `navigator.onLine` hint) |
|
||||||
| `connectivity_set_server_url` | Update monitored server URL |
|
| `connectivity_set_server_url` | Update monitored server URL |
|
||||||
| `connectivity_get_status` | Get current connectivity status |
|
| `connectivity_get_status` | Get current connectivity status |
|
||||||
| `connectivity_start_monitoring` | Start background monitoring |
|
| `connectivity_start_monitoring` | Start the offline recovery probe |
|
||||||
| `connectivity_stop_monitoring` | Stop monitoring |
|
| `connectivity_stop_monitoring` | Stop the probe |
|
||||||
| `connectivity_mark_reachable` | Mark server as reachable (after successful API call) |
|
| `connectivity_mark_reachable` | Mark reachable — driven by `OnlineRepository` on every server success |
|
||||||
| `connectivity_mark_unreachable` | Mark server as unreachable (after failed API call) |
|
| `connectivity_mark_unreachable` | Mark unreachable — driven by `OnlineRepository` on `RepoError::Network` (subject to debounce) |
|
||||||
|
|
||||||
**Frontend Integration:**
|
**Frontend Integration:**
|
||||||
```typescript
|
```typescript
|
||||||
// TypeScript store listens to Rust events
|
// The store is a pure reflection of backend events — it no longer decides
|
||||||
|
// reachability itself. navigator.onLine is advisory: it triggers an immediate
|
||||||
|
// recheck rather than forcing the offline state.
|
||||||
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
||||||
updateConnectivityState(event.payload.isReachable);
|
updateConnectivityState(event.payload.isReachable);
|
||||||
});
|
});
|
||||||
@@ -81,12 +114,14 @@ listen<{ isReachable: boolean }>("connectivity:changed", (event) => {
|
|||||||
The connectivity system provides resilience through multiple layers:
|
The connectivity system provides resilience through multiple layers:
|
||||||
|
|
||||||
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
1. **HTTP Client Layer**: Automatic retry with exponential backoff
|
||||||
2. **Connectivity Monitoring**: Background reachability checks
|
2. **Connectivity Monitoring**: Reachability derived from real repository traffic, with an offline-only recovery probe
|
||||||
3. **Frontend Integration**: Offline mode detection and UI updates
|
3. **Frontend Integration**: Offline mode detection and UI updates (a pure reflection of backend events)
|
||||||
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
|
4. **Sync Queue**: Offline mutations queued for later (see [06-downloads-and-offline.md](06-downloads-and-offline.md))
|
||||||
|
|
||||||
**Design Principles:**
|
**Design Principles:**
|
||||||
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication)
|
- **Single source of truth**: Reachability follows the outcome of real requests, classified via `RepoError`; the frontend store and the probe never compete to decide it.
|
||||||
- **Fail Slow**: Retry network and 5xx errors with increasing delays
|
- **Fail Fast**: Don't retry 4xx errors (client errors, authentication).
|
||||||
- **Adaptive Polling**: Reduce polling frequency when online, increase when offline
|
- **Fail Slow**: Retry network and 5xx errors with increasing delays.
|
||||||
- **Event-Driven**: Frontend reacts to connectivity changes via events
|
- **Debounced offline, instant online**: Declare offline only after a sustained failure window; recover on the first success.
|
||||||
|
- **Probe only when needed**: Background polling runs only while offline, as a recovery detector.
|
||||||
|
- **Event-Driven**: Frontend reacts to connectivity changes via events.
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
|
|||||||
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
|
||||||
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
|
||||||
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
|
||||||
|
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
|
||||||
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
|
||||||
- **Cache-First**: Parallel queries with intelligent fallback.
|
- **Cache-First**: Parallel queries with intelligent fallback.
|
||||||
|
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
|
||||||
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
|
||||||
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
|
||||||
|
|
||||||
@@ -79,26 +81,29 @@ flowchart TB
|
|||||||
Core --> Storage
|
Core --> Storage
|
||||||
Repository --> HttpClient
|
Repository --> HttpClient
|
||||||
Repository --> DatabaseService
|
Repository --> DatabaseService
|
||||||
|
Repository -->|"reports server outcome<br/>(success / RepoError)"| ConnectivityMonitor
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The `Repository --> ConnectivityMonitor` edge is the source of truth for the offline/online banner: every server request the user actually makes updates reachability. The monitor's own polling is now an offline-only recovery probe (see [07-connectivity.md](07-connectivity.md)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Detailed Documentation
|
## Detailed Documentation
|
||||||
|
|
||||||
Each major subsystem is documented in its own file under [docs/architecture/](docs/architecture/):
|
Each major subsystem is documented in its own file in this directory:
|
||||||
|
|
||||||
| Document | Contents |
|
| Document | Contents |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| [01 - Rust Backend](docs/architecture/01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
|
| [01 - Rust Backend](01-rust-backend.md) | Media session state machine, player state machine, playback mode, media items, queue manager, favorites, player backend trait, player controller, playlist system, Tauri commands |
|
||||||
| [02 - Svelte Frontend](docs/architecture/02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
|
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI |
|
||||||
| [03 - Data Flow](docs/architecture/03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), playback initiation, playback mode transfer, queue navigation, volume control |
|
||||||
| [04 - Type Sync & Threading](docs/architecture/04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
||||||
| [05 - Platform Backends](docs/architecture/05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession & remote volume, album art caching, backend initialization |
|
||||||
| [06 - Downloads & Offline](docs/architecture/06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
|
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, download/offline commands, player integration, frontend store, UI components |
|
||||||
| [07 - Connectivity](docs/architecture/07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
||||||
| [08 - Database Design](docs/architecture/08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
||||||
| [09 - Security](docs/architecture/09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
| [09 - Security](09-security.md) | Authentication token storage, secure storage module, network security, local data protection |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -162,6 +167,9 @@ src/lib/
|
|||||||
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
|
||||||
│ ├── client.ts # JellyfinClient (helper for streaming)
|
│ ├── client.ts # JellyfinClient (helper for streaming)
|
||||||
│ └── sessions.ts # SessionsApi (remote session control)
|
│ └── sessions.ts # SessionsApi (remote session control)
|
||||||
|
├── player/ # Unified player boundary (frontend)
|
||||||
|
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
||||||
|
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||||
@@ -187,7 +195,7 @@ src/lib/
|
|||||||
|
|
||||||
**What moved to Rust (~3,500 lines of business logic):**
|
**What moved to Rust (~3,500 lines of business logic):**
|
||||||
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
1. **HTTP Client** (338 lines) - Retry logic with exponential backoff
|
||||||
2. **Connectivity Monitor** (301 lines) - Adaptive polling, event emission
|
2. **Connectivity Monitor** (301 lines) - Reachability derived from real repository traffic, time-window debounce, offline-only recovery probe, event emission
|
||||||
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
|
3. **Repository Pattern** (1061 lines) - Cache-first hybrid with parallel racing
|
||||||
4. **Database Service** - Async wrapper preventing UI freezing
|
4. **Database Service** - Async wrapper preventing UI freezing
|
||||||
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
5. **Playback Mode** (303 lines) - Local/remote transfer coordination
|
||||||
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,454 @@
|
|||||||
|
# Requirements Specification
|
||||||
|
|
||||||
|
This document captures JellyTau's user requirements, software requirements,
|
||||||
|
traceability matrix, test traceability, and known technical debt.
|
||||||
|
|
||||||
|
For a narrative overview of the system design, see
|
||||||
|
[docs/architecture/](architecture/). For development workflows, see the
|
||||||
|
[README](../README.md) and [scripts/README.md](../scripts/README.md).
|
||||||
|
|
||||||
|
## 1. User Requirements
|
||||||
|
|
||||||
|
| ID | Requirement | Priority | Status |
|
||||||
|
|----|-------------|----------|--------|
|
||||||
|
| UR-001 | Run the app on multiple platforms (Linux, Android) | High | In Progress |
|
||||||
|
| UR-002 | Access media when online or offline | High | Done |
|
||||||
|
| UR-003 | Play videos | High | Done |
|
||||||
|
| UR-004 | Play audio uninterrupted | High | Done |
|
||||||
|
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
|
||||||
|
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
|
||||||
|
| UR-007 | Navigate media in library | High | Done |
|
||||||
|
| UR-008 | Search media across libraries | High | Done |
|
||||||
|
| UR-009 | Connect to Jellyfin to access media | High | Done |
|
||||||
|
| UR-010 | Control playback of Jellyfin remote sessions | Low | Done |
|
||||||
|
| UR-011 | Download media on demand | Medium | Done |
|
||||||
|
| UR-012 | Login info shall be stored securely and persistently | High | Done |
|
||||||
|
| UR-013 | View and manage downloaded media | Medium | Done |
|
||||||
|
| UR-014 | Make and edit playlists of music that sync back to Jellyfin | Medium | Done |
|
||||||
|
| UR-015 | View and manage current audio queue (add, reorder tracks) | Medium | Done |
|
||||||
|
| UR-016 | Change system settings while playing (brightness, volume) | Low | Planned |
|
||||||
|
| UR-017 | Like or unlike audio, albums, movies, etc. | Medium | Done |
|
||||||
|
| UR-018 | Choose to download series, albums, songs, artist discography | Medium | Done |
|
||||||
|
| UR-019 | Resume playback from where you left off (movies, shows, albums) | High | Done |
|
||||||
|
| UR-020 | Select subtitles for video content | High | Done |
|
||||||
|
| UR-021 | Select audio track for video content | High | Done |
|
||||||
|
| UR-022 | Control streaming quality and transcoding settings | Medium | Planned |
|
||||||
|
| UR-023 | View "Next Up" / Continue Watching on home screen; auto-play next episode with countdown popup and configurable episode limit | Medium | Done |
|
||||||
|
| UR-024 | View recently added content on server | Medium | Done |
|
||||||
|
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
|
||||||
|
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
|
||||||
|
| UR-027 | Audio equalizer for sound customization | Low | Planned |
|
||||||
|
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
|
||||||
|
| UR-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
|
| UR-030 | Quick genre browsing and filtering | Medium | Done |
|
||||||
|
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
|
||||||
|
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
|
||||||
|
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
|
||||||
|
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||||
|
| UR-035 | View cast/crew (actors, directors) on movie/show detail pages | High | Done |
|
||||||
|
| UR-036 | Navigate to actor/person page showing their filmography | Medium | Done |
|
||||||
|
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
|
||||||
|
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
|
||||||
|
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Software Requirements
|
||||||
|
|
||||||
|
### 2.1 Integration Requirements
|
||||||
|
|
||||||
|
External system integrations and platform-specific implementations.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| IR-001 | Build system supporting multiple targets (Linux, Android) | Build | UR-001 | Done |
|
||||||
|
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
|
||||||
|
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
|
||||||
|
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
|
||||||
|
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
|
||||||
|
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
|
||||||
|
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
|
||||||
|
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
|
||||||
|
| IR-009 | Jellyfin API client for authentication | API | UR-009, UR-012 | Done |
|
||||||
|
| IR-010 | Jellyfin API client for library browsing | API | UR-007, UR-008 | Done |
|
||||||
|
| IR-011 | Jellyfin API client for playback streaming | API | UR-003, UR-004 | Done |
|
||||||
|
| IR-012 | Jellyfin Sessions API for remote playback control | API | UR-010 | Done |
|
||||||
|
| IR-021 | Android MediaRouter integration for remote volume in system panel | Platform | UR-010, UR-016 | Planned |
|
||||||
|
| IR-013 | SQLite integration for local database | Storage | UR-002, UR-011 | Done |
|
||||||
|
| IR-014 | Secure credential storage (keyring/keychain) | Security | UR-012 | Done |
|
||||||
|
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
|
||||||
|
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
|
||||||
|
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
|
||||||
|
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
|
||||||
|
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
|
||||||
|
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
|
||||||
|
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
|
||||||
|
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
|
||||||
|
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
|
||||||
|
|
||||||
|
### 2.2 Jellyfin API Requirements
|
||||||
|
|
||||||
|
API endpoints and data contracts required for Jellyfin integration.
|
||||||
|
|
||||||
|
| ID | Requirement | Endpoint Category | Traces To | Status |
|
||||||
|
|----|-------------|-------------------|-----------|--------|
|
||||||
|
| JA-001 | Server connection and discovery | System | UR-009 | Done |
|
||||||
|
| JA-002 | User authentication (username/password) | Users | UR-009, UR-012 | Done |
|
||||||
|
| JA-003 | Get user library views | UserViews | UR-007 | Done |
|
||||||
|
| JA-004 | Get library items (paginated) | Items | UR-007 | Done |
|
||||||
|
| JA-005 | Get item details and metadata | Items | UR-007 | Done |
|
||||||
|
| JA-006 | Search across libraries | Items | UR-008 | Done |
|
||||||
|
| JA-007 | Get playback info and stream URL | MediaInfo | UR-003, UR-004 | Done |
|
||||||
|
| JA-008 | Get available subtitles for item | MediaInfo | UR-020 | Done |
|
||||||
|
| JA-009 | Get available audio tracks for item | MediaInfo | UR-021 | Done |
|
||||||
|
| JA-010 | Report playback start | Sessions | UR-025 | Done |
|
||||||
|
| JA-011 | Report playback progress (periodic) | Sessions | UR-025 | Done |
|
||||||
|
| JA-012 | Report playback stopped | Sessions | UR-025 | Done |
|
||||||
|
| JA-013 | Get resume position for item | UserData | UR-019 | Done |
|
||||||
|
| JA-014 | Get "Next Up" items | Shows | UR-023 | Done |
|
||||||
|
| JA-015 | Get "Continue Watching" items | Items | UR-023 | Done |
|
||||||
|
| JA-016 | Get recently added items | Items | UR-024 | Done |
|
||||||
|
| JA-017 | Mark item as favorite | UserData | UR-017 | Done |
|
||||||
|
| JA-018 | Remove item from favorites | UserData | UR-017 | Done |
|
||||||
|
| JA-019 | Get/create/update playlists | Playlists | UR-014 | Done |
|
||||||
|
| JA-020 | Add/remove items from playlist | Playlists | UR-014 | Done |
|
||||||
|
| JA-021 | Get active sessions list | Sessions | UR-010 | Done |
|
||||||
|
| JA-022 | Send playback commands to remote session (play/pause/stop) | Sessions | UR-010 | Done |
|
||||||
|
| JA-023 | Send seek command to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-024 | Send next/previous track commands to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-025 | Play specific item on remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-026 | Send volume/mute commands to remote session | Sessions | UR-010 | Done |
|
||||||
|
| JA-027 | Get transcoding options | MediaInfo | UR-022 | Planned |
|
||||||
|
| JA-028 | Get image/artwork URLs | Images | UR-007 | Done |
|
||||||
|
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
|
||||||
|
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
|
||||||
|
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
|
||||||
|
|
||||||
|
### 2.3 Development Requirements
|
||||||
|
|
||||||
|
Internal architecture, components, and application logic.
|
||||||
|
|
||||||
|
| ID | Requirement | Category | Traces To | Status |
|
||||||
|
|----|-------------|----------|-----------|--------|
|
||||||
|
| DR-001 | Player state machine (idle, loading, playing, paused, seeking, error) | Player | UR-005 | Done |
|
||||||
|
| DR-002 | MediaItem struct tracking source, location, duration, metadata | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-003 | Source-agnostic media abstraction (Remote, Local, DirectUrl) | Player | UR-002, UR-011 | Done |
|
||||||
|
| DR-004 | PlayerBackend trait for platform-agnostic playback | Player | UR-003, UR-004 | Done |
|
||||||
|
| DR-005 | Queue manager with shuffle, repeat, history | Player | UR-005, UR-015 | Done |
|
||||||
|
| DR-006 | Audio pre-caching for seamless track transitions | Player | UR-004 | Planned |
|
||||||
|
| DR-007 | Library browsing screens (grid view, search, filters) | UI | UR-007, UR-008 | Done |
|
||||||
|
| DR-008 | Album/Series detail view with track listing | UI | UR-007 | Done |
|
||||||
|
| DR-009 | Audio player UI (mini player, full screen) | UI | UR-005 | Done |
|
||||||
|
| DR-010 | Video player UI (fullscreen, controls overlay) | UI | UR-003, UR-005 | Done |
|
||||||
|
| DR-011 | Search bar with cross-library search | UI | UR-008 | Done |
|
||||||
|
| DR-012 | Local database for media metadata cache | Storage | UR-002 | Done |
|
||||||
|
| DR-013 | Repository pattern for online/offline data access | Storage | UR-002 | Done |
|
||||||
|
| DR-014 | Offline mutation queue for sync-back operations | Storage | UR-002, UR-014, UR-017 | Done |
|
||||||
|
| DR-015 | Download manager with queue and progress tracking | Storage | UR-011, UR-018 | Done |
|
||||||
|
| DR-016 | Thumbnail caching and sync with server | Storage | UR-007 | Done |
|
||||||
|
| DR-017 | "Manage Downloads" screen for local media management | UI | UR-013 | Done |
|
||||||
|
| DR-018 | Download buttons on library/album/player screens | UI | UR-011, UR-018 | Done |
|
||||||
|
| DR-019 | Playlist creation and editing UI | UI | UR-014 | Done |
|
||||||
|
| DR-020 | Queue management UI (add, remove, reorder) | UI | UR-015 | Done |
|
||||||
|
| DR-021 | Like/favorite functionality on media items | UI | UR-017 | Done |
|
||||||
|
| DR-022 | Resume position tracking and restoration on play | Player | UR-019 | Done |
|
||||||
|
| DR-023 | Subtitle selection UI in video player | UI | UR-020 | Done |
|
||||||
|
| DR-024 | Audio track selection UI in video player | UI | UR-021 | Done |
|
||||||
|
| DR-025 | Quality/transcoding settings UI | UI | UR-022 | Planned |
|
||||||
|
| DR-026 | "Continue Watching" / "Next Up" home section | UI | UR-023 | Done |
|
||||||
|
| DR-027 | "Recently Added" home section | UI | UR-024 | Done |
|
||||||
|
| DR-028 | Playback progress sync service (periodic reporting) | Player | UR-025 | Done |
|
||||||
|
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
|
||||||
|
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
|
||||||
|
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
|
||||||
|
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
|
||||||
|
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||||
|
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
||||||
|
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
|
||||||
|
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
|
||||||
|
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
|
||||||
|
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
|
||||||
|
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||||
|
| DR-038 | Home screen with hero banner carousel (featured/continue watching) | UI | UR-034 | Done |
|
||||||
|
| DR-039 | Home screen horizontal carousels (recently added, recommendations) | UI | UR-034, UR-024 | Done |
|
||||||
|
| DR-040 | Cast/crew section on movie/show detail pages | UI | UR-035 | Done |
|
||||||
|
| DR-041 | Person/actor detail page with filmography grid | UI | UR-036 | Done |
|
||||||
|
| DR-042 | Video library grid with poster cards, year, and rating badges | UI | UR-037 | Done |
|
||||||
|
| DR-043 | Movie/show detail page with backdrop hero, synopsis, and metadata | UI | UR-038 | Done |
|
||||||
|
| DR-044 | Horizontal scrolling actor/cast row with profile images | UI | UR-035 | Done |
|
||||||
|
| DR-045 | Bottom navigation bar with Home, Library, Search buttons | UI | UR-039 | Done |
|
||||||
|
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
|
||||||
|
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
|
||||||
|
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Traceability Matrix
|
||||||
|
|
||||||
|
### User Requirements to Software Requirements
|
||||||
|
|
||||||
|
| User Req | Integration Requirements | Development Requirements |
|
||||||
|
|----------|-------------------------|-------------------------|
|
||||||
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
|
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||||
|
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
|
||||||
|
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
|
||||||
|
| UR-005 | - | DR-001, DR-005, DR-009 |
|
||||||
|
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
|
||||||
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
|
||||||
|
| UR-008 | IR-010 | DR-007, DR-011 |
|
||||||
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
|
| UR-010 | IR-012, IR-021 | DR-037 |
|
||||||
|
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||||
|
| UR-012 | IR-009, IR-014 | - |
|
||||||
|
| UR-013 | IR-013 | DR-017 |
|
||||||
|
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||||
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
|
| UR-016 | - | - |
|
||||||
|
| UR-017 | - | DR-014, DR-021 |
|
||||||
|
| UR-018 | IR-013 | DR-015, DR-018 |
|
||||||
|
| UR-019 | IR-015 | DR-022 |
|
||||||
|
| UR-020 | IR-016, IR-018 | DR-023 |
|
||||||
|
| UR-021 | IR-016, IR-019 | DR-024 |
|
||||||
|
| UR-022 | IR-017 | DR-025 |
|
||||||
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
||||||
|
| UR-024 | IR-010 | DR-027 |
|
||||||
|
| UR-025 | IR-015 | DR-028 |
|
||||||
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
|
| UR-027 | IR-020 | DR-030 |
|
||||||
|
| UR-028 | - | DR-031 |
|
||||||
|
| UR-029 | - | DR-032 |
|
||||||
|
| UR-030 | IR-010 | DR-033 |
|
||||||
|
| UR-031 | - | DR-034 |
|
||||||
|
| UR-032 | - | DR-035 |
|
||||||
|
| UR-033 | - | DR-036 |
|
||||||
|
| UR-034 | IR-010, IR-024 | DR-038, DR-039 |
|
||||||
|
| UR-035 | IR-022, IR-023 | DR-040, DR-044 |
|
||||||
|
| UR-036 | IR-022, IR-023 | DR-041 |
|
||||||
|
| UR-037 | IR-010 | DR-042 |
|
||||||
|
| UR-038 | IR-010 | DR-043 |
|
||||||
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Test Traceability
|
||||||
|
|
||||||
|
### Unit Tests to Software Requirements
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| UT-001 | Player state transitions | DR-001 | Pending |
|
||||||
|
| UT-002 | MediaItem source URL resolution | DR-002, DR-003 | Pending |
|
||||||
|
| UT-003 | Queue next/previous navigation | DR-005 | Pending |
|
||||||
|
| UT-004 | Queue shuffle order generation | DR-005 | Pending |
|
||||||
|
| UT-005 | Queue repeat mode behavior | DR-005 | Pending |
|
||||||
|
| UT-006 | Jellyfin authentication flow | IR-009 | Pending |
|
||||||
|
| UT-007 | Jellyfin library items parsing | IR-010 | Pending |
|
||||||
|
| UT-008 | Repository pattern online/offline switching | DR-013 | Pending |
|
||||||
|
| UT-009 | Offline mutation queue persistence | DR-014 | Pending |
|
||||||
|
| UT-010 | Download queue management | DR-015 | Done |
|
||||||
|
| UT-011 | Resume position storage and retrieval | DR-022 | Pending |
|
||||||
|
| UT-012 | Sleep timer countdown logic | DR-029 | Pending |
|
||||||
|
| UT-013 | Playback progress reporting throttling | DR-028 | Pending |
|
||||||
|
| UT-014 | Database open and in-memory mode | IR-013, DR-012 | Done |
|
||||||
|
| UT-015 | Database migrations run successfully | IR-013, DR-012 | Done |
|
||||||
|
| UT-016 | All database tables created | IR-013, DR-012 | Done |
|
||||||
|
| UT-017 | FTS5 search table created | IR-013, DR-012 | Done |
|
||||||
|
| UT-018 | Server CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-019 | User CRUD operations | IR-013, DR-012 | Done |
|
||||||
|
| UT-020 | Cascade delete server removes users | IR-013, DR-012 | Done |
|
||||||
|
| UT-021 | Item insert and FTS search | IR-013, DR-012 | Done |
|
||||||
|
| UT-022 | User data playback position storage | IR-013, DR-012, DR-022 | Done |
|
||||||
|
| UT-023 | Sync queue operations | IR-013, DR-014 | Done |
|
||||||
|
| UT-024 | Downloads table operations | IR-013, DR-015 | Done |
|
||||||
|
| UT-025 | Migrations are idempotent | IR-013, DR-012 | Done |
|
||||||
|
| UT-026 | NullBackend volume default value | DR-004 | Done |
|
||||||
|
| UT-027 | NullBackend set volume | DR-004 | Done |
|
||||||
|
| UT-028 | NullBackend volume clamping (high/low) | DR-004 | Done |
|
||||||
|
| UT-029 | NullBackend volume boundary values | DR-004 | Done |
|
||||||
|
| UT-030 | PlayerController volume default | DR-004, DR-009 | Done |
|
||||||
|
| UT-031 | PlayerController set volume | DR-004, DR-009 | Done |
|
||||||
|
| UT-032 | PlayerController muted default | DR-004, DR-009 | Done |
|
||||||
|
| UT-033 | PlayerController volume delegates to backend | DR-004, DR-009 | Done |
|
||||||
|
| UT-034 | Download event serialization roundtrip | DR-015 | Done |
|
||||||
|
| UT-035 | Download event completed serialization | DR-015 | Done |
|
||||||
|
| UT-036 | Download event failed serialization | DR-015 | Done |
|
||||||
|
| UT-037 | Download worker exponential backoff | DR-015 | Done |
|
||||||
|
| UT-038 | Download worker error retryable check | DR-015 | Done |
|
||||||
|
| UT-039 | Download manager creation | DR-015 | Done |
|
||||||
|
| UT-040 | Download manager set max concurrent | DR-015 | Done |
|
||||||
|
| UT-041 | Download info serialization | DR-015 | Done |
|
||||||
|
| UT-042 | Download command filename sanitization | DR-015, DR-018 | Done |
|
||||||
|
| UT-043 | Download command filename extension preservation | DR-015, DR-018 | Done |
|
||||||
|
| UT-044 | Offline item serialization | DR-017 | Done |
|
||||||
|
| UT-045 | Smart cache default config | DR-015 | Done |
|
||||||
|
| UT-046 | Smart cache album affinity tracking | DR-015 | Done |
|
||||||
|
| UT-047 | Smart cache queue precache config | DR-015 | Done |
|
||||||
|
| UT-048 | Smart cache storage limit check | DR-015 | Done |
|
||||||
|
| UT-049 | Playlist create (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-050 | Playlist delete (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-051 | Playlist rename (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-052 | Playlist get items (offline) | DR-019, JA-019 | Done |
|
||||||
|
| UT-053 | Playlist add items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-054 | Playlist remove items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-055 | Playlist reorder items (offline) | DR-019, JA-020 | Done |
|
||||||
|
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
|
||||||
|
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
|
||||||
|
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| IT-001 | End-to-end authentication with Jellyfin server | IR-009, UR-009 | Pending |
|
||||||
|
| IT-002 | Library browsing and item loading | IR-010, UR-007 | Pending |
|
||||||
|
| IT-003 | Audio playback via libmpv | IR-003, UR-004 | Pending |
|
||||||
|
| IT-004 | Video playback via libmpv | IR-003, UR-003 | Pending |
|
||||||
|
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
|
||||||
|
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
|
||||||
|
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
|
||||||
|
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
|
||||||
|
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
|
||||||
|
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
|
||||||
|
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||||
|
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technical Debt
|
||||||
|
|
||||||
|
### Linux Keyring Integration Workaround
|
||||||
|
|
||||||
|
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Credentials are saved to the system keyring successfully (verified with `secret-tool search`)
|
||||||
|
- Retrieval via the `keyring-rs` library fails with `NoEntry` error
|
||||||
|
- Session restoration fails on app restart even though credentials exist
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `keyring-rs` library's Linux backend doesn't correctly retrieve entries from the Secret Service that it previously stored. This appears to be a bug in how the library interfaces with the Secret Service D-Bus API.
|
||||||
|
|
||||||
|
**Current Workaround**:
|
||||||
|
We bypass the `keyring-rs` library on Linux and use direct system calls to `secret-tool`:
|
||||||
|
- **Save**: `secret-tool store --label <label> service <service> username <username>`
|
||||||
|
- **Retrieve**: `secret-tool lookup service <service> username <username>`
|
||||||
|
- **Delete**: `secret-tool clear service <service> username <username>`
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
See [src-tauri/src/credentials.rs](../src-tauri/src/credentials.rs) for the
|
||||||
|
Linux-specific `secret-tool` save/get/delete paths.
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
- Monitor `keyring-rs` for bug fixes in future versions
|
||||||
|
- Consider alternative secure storage libraries
|
||||||
|
- Test if newer versions of `keyring-rs` (v4.x+) resolve the issue
|
||||||
|
- Once fixed, remove the Linux-specific workaround and use the cross-platform `keyring-rs` API
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - The workaround is functionally equivalent to proper keyring integration
|
||||||
|
- Credentials are stored securely in the system keyring
|
||||||
|
- Session restoration works correctly
|
||||||
|
- Only affects Linux; macOS and Windows use the standard `keyring-rs` implementation
|
||||||
|
|
||||||
|
**Dependencies**:
|
||||||
|
- Requires `secret-tool` to be installed on Linux systems (part of `libsecret-tools` package)
|
||||||
|
- Already available on most Linux distributions by default
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Platform Playback Backend Parity (Linux vs Android)
|
||||||
|
|
||||||
|
**Issue**: The Linux (MPV) and Android (ExoPlayer) playback backends have diverged in feature implementation and architecture patterns.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Audio settings (crossfade, gapless playback, volume normalization) work on Linux but not on Android
|
||||||
|
- Position update frequency differs between platforms (Linux: 250ms polling, Android: on-demand callbacks)
|
||||||
|
- Thread safety models differ (Linux: `Arc<Mutex<>>`, Android: global `OnceLock` statics)
|
||||||
|
|
||||||
|
**Root Cause**:
|
||||||
|
The `PlayerBackend` trait defines optional audio settings methods with default empty implementations. The Linux `MpvBackend` overrides these with full MPV property commands, but `ExoPlayerBackend` uses the defaults.
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src-tauri/src/player/backend.rs](../src-tauri/src/player/backend.rs) - Trait with default empty implementations
|
||||||
|
- [src-tauri/src/player/mpv_backend.rs](../src-tauri/src/player/mpv_backend.rs) - Full audio settings support
|
||||||
|
- [src-tauri/src/player/android/mod.rs](../src-tauri/src/player/android/mod.rs) - Missing audio settings implementation
|
||||||
|
|
||||||
|
**Feature Parity Matrix**:
|
||||||
|
|
||||||
|
| Feature | Linux (MPV) | Android (ExoPlayer) | Status |
|
||||||
|
|---------|-------------|---------------------|--------|
|
||||||
|
| Basic playback | ✅ | ✅ | Parity |
|
||||||
|
| Volume control | ✅ | ✅ | Parity |
|
||||||
|
| Seek | ✅ | ✅ | Parity |
|
||||||
|
| Crossfade | ✅ | ❌ | Gap |
|
||||||
|
| Gapless playback | ✅ | ❌ | Gap |
|
||||||
|
| Volume normalization | ✅ | ❌ | Gap |
|
||||||
|
| Position updates | 250ms | On-demand | Inconsistent |
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
||||||
|
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
||||||
|
3. Implement gapless via ExoPlayer's built-in gapless support
|
||||||
|
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
||||||
|
5. Standardize position update frequency across platforms
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||||
|
- User experience differs between platforms
|
||||||
|
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
|
||||||
|
|
||||||
|
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend Playback Code Duplication
|
||||||
|
|
||||||
|
**Issue**: Playback control handlers and state derivations are duplicated between `AudioPlayer.svelte` and `MiniPlayer.svelte`.
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- Identical try-catch wrapped handler functions in both components (~44 lines duplicated)
|
||||||
|
- Same `$derived` state merging logic for local/remote playback in both components
|
||||||
|
- Position conversion (ticks ↔ seconds) scattered across multiple files
|
||||||
|
|
||||||
|
**Affected Files**:
|
||||||
|
- [src/lib/components/player/AudioPlayer.svelte](../src/lib/components/player/AudioPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/components/player/MiniPlayer.svelte](../src/lib/components/player/MiniPlayer.svelte) - Duplicate handlers
|
||||||
|
- [src/lib/services/playbackControl.ts](../src/lib/services/playbackControl.ts) - Position conversion
|
||||||
|
- [src/lib/stores/playbackMode.ts](../src/lib/stores/playbackMode.ts) - Position conversion
|
||||||
|
- [src/lib/services/playbackReporting.ts](../src/lib/services/playbackReporting.ts) - Position conversion
|
||||||
|
|
||||||
|
**Duplicated Code**:
|
||||||
|
```typescript
|
||||||
|
// These handlers are identical in both AudioPlayer and MiniPlayer:
|
||||||
|
handlePlayPause(), handleNext(), handlePrevious(),
|
||||||
|
handleToggleShuffle(), handleCycleRepeat(), handleVolumeChange()
|
||||||
|
|
||||||
|
// These derived states use identical logic:
|
||||||
|
displayMedia, displayIsPlaying, displayPosition, displayDuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
1. Create `src/lib/utils/playbackUnits.ts`:
|
||||||
|
```typescript
|
||||||
|
export const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
export const secondsToTicks = (s: number) => Math.floor(s * TICKS_PER_SECOND);
|
||||||
|
export const ticksToSeconds = (t: number) => t / TICKS_PER_SECOND;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `src/lib/composables/useMergedPlaybackState.svelte.ts`:
|
||||||
|
- Export `displayMedia`, `displayIsPlaying`, `displayPosition`, `displayDuration`
|
||||||
|
- Single source of truth for merged local/remote state
|
||||||
|
|
||||||
|
3. Simplify handler wrappers using a utility:
|
||||||
|
```typescript
|
||||||
|
export const withErrorHandler = (fn: () => Promise<void>, context: string) =>
|
||||||
|
async () => { try { await fn(); } catch (e) { console.error(`${context}:`, e); } };
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact**:
|
||||||
|
- Low - Code works correctly but violates DRY principle
|
||||||
|
- Maintenance burden when logic needs to change
|
||||||
|
- Risk of handlers diverging over time
|
||||||
|
|
||||||
|
**Traces To**: DR-009
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.1.0",
|
"version": "0.0.15",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
"test:rust": "./scripts/test-rust.sh",
|
"test:rust": "./scripts/test-rust.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"android:build:release": "./scripts/build-android.sh release",
|
||||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && npm install && npm run build",
|
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||||
"android:deploy": "./scripts/deploy-android.sh",
|
"android:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
"android:check": "./scripts/check-android.sh",
|
"android:check": "./scripts/check-android.sh",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ The traceability system is integrated with Gitea Actions CI/CD:
|
|||||||
|
|
||||||
For details, see:
|
For details, see:
|
||||||
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
- [Traceability CI Guide](../docs/traceability-ci.md) - Full CI/CD documentation
|
||||||
- [TRACES Quick Reference](../traces-quick-ref.md) - Quick guide for adding TRACES
|
- [TRACES Quick Reference](../docs/traces-quick-ref.md) - Quick guide for adding TRACES
|
||||||
|
|
||||||
## Utility Scripts
|
## Utility Scripts
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,19 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
BUILD_TYPE="${1:-debug}"
|
|
||||||
|
|
||||||
echo "🚀 Build and Deploy Android APK"
|
echo "🚀 Build and Deploy Android APK"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build APK
|
# Pass all args (build type and/or --clean) through to the build script.
|
||||||
./scripts/build-android.sh "$BUILD_TYPE"
|
./scripts/build-android.sh "$@"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Deploy APK
|
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
./scripts/deploy-android.sh "$BUILD_TYPE"
|
./scripts/deploy-android.sh "$BUILD_TYPE"
|
||||||
|
|||||||
@@ -15,13 +15,24 @@ echo "Android SDK: $ANDROID_HOME"
|
|||||||
echo "NDK: $NDK_HOME"
|
echo "NDK: $NDK_HOME"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Build type: debug or release (default: debug)
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
BUILD_TYPE="${1:-debug}"
|
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
||||||
|
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
|
||||||
|
BUILD_TYPE="debug"
|
||||||
|
CLEAN="${CLEAN:-0}"
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--clean) CLEAN=1 ;;
|
||||||
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
# Step 0: Clear build caches to ensure fresh builds
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
echo "🧹 Clearing build caches..."
|
if [ "$CLEAN" = "1" ]; then
|
||||||
|
echo "🧹 Clearing build caches (clean build)..."
|
||||||
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
|
||||||
npm install > /dev/null 2>&1
|
npm install > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
# Step 1: Sync Android source files
|
# Step 1: Sync Android source files
|
||||||
echo "🔄 Syncing Android sources..."
|
echo "🔄 Syncing Android sources..."
|
||||||
@@ -33,6 +44,9 @@ bun run build
|
|||||||
|
|
||||||
# Step 2: Build Android APK
|
# Step 2: Build Android APK
|
||||||
if [ "$BUILD_TYPE" = "release" ]; then
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
# Configure release signing from .env (single source of truth). Must run
|
||||||
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ interface TracesData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repo root, derived from this script's location (scripts/ -> repo root).
|
||||||
|
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
|
||||||
|
const BASE_DIR = path.resolve(import.meta.dir, "..");
|
||||||
|
|
||||||
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ function extractRequirementIds(tracesString: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAllSourceFiles(): string[] {
|
function getAllSourceFiles(): string[] {
|
||||||
const baseDir = "/home/dtourolle/Development/JellyTau";
|
const baseDir = BASE_DIR;
|
||||||
const patterns = ["src", "src-tauri/src"];
|
const patterns = ["src", "src-tauri/src"];
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ function extractTraces(): TracesData {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let totalTraces = 0;
|
let totalTraces = 0;
|
||||||
const baseDir = "/home/dtourolle/Development/JellyTau";
|
const baseDir = BASE_DIR;
|
||||||
|
|
||||||
const files = getAllSourceFiles();
|
const files = getAllSourceFiles();
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,49 @@ if [ -f "$APP_GRADLE_SRC" ]; then
|
|||||||
echo " Copied: app/build.gradle.kts"
|
echo " Copied: app/build.gradle.kts"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Custom ProGuard/R8 keep rules. Required for minified release builds:
|
||||||
|
# the player/ and security/ Kotlin classes are loaded by name via JNI from
|
||||||
|
# Rust, so R8 can't see the references and would strip them without this.
|
||||||
|
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
|
||||||
|
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
|
||||||
|
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
|
||||||
|
if [ -f "$PROGUARD_SRC" ]; then
|
||||||
|
cp "$PROGUARD_SRC" "$PROGUARD_DST"
|
||||||
|
echo " Copied: app/proguard-jellytau.pro"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
|
||||||
|
# low-quality launcher icons from tauri.conf.json (which has no high-res
|
||||||
|
# Android source), so overwrite them with the real committed mipmaps.
|
||||||
|
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
|
||||||
|
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
|
||||||
|
if [ -d "$RES_SRC" ]; then
|
||||||
|
for dir in "$RES_SRC"/mipmap-*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
name="$(basename "$dir")"
|
||||||
|
mkdir -p "$RES_DST/$name"
|
||||||
|
cp "$dir"/* "$RES_DST/$name/"
|
||||||
|
echo " Copied res: $name"
|
||||||
|
done
|
||||||
|
# We ship only the color adaptive icon (background + foreground). Drop any
|
||||||
|
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
|
||||||
|
# render well, and our adaptive-icon xml no longer references it, so a stray
|
||||||
|
# ic_launcher_monochrome.png would just be dead weight.
|
||||||
|
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
|
||||||
|
|
||||||
|
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
|
||||||
|
# as API-qualified VECTOR drawables:
|
||||||
|
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
|
||||||
|
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
|
||||||
|
# Because drawable-v24 is a more specific match than our unqualified
|
||||||
|
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
|
||||||
|
# the green square robot instead of our jellyfish. Remove them so the
|
||||||
|
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
|
||||||
|
# to the real committed PNGs.
|
||||||
|
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
|
||||||
|
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
|
||||||
|
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
|
||||||
|
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
|
||||||
|
#
|
||||||
|
# .env is the single source of truth for local release signing. `tauri android
|
||||||
|
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
|
||||||
|
# from .env before every release build (this is the local mirror of what the CI
|
||||||
|
# workflow does from Gitea secrets).
|
||||||
|
#
|
||||||
|
# Required .env vars:
|
||||||
|
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
|
||||||
|
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ENV_FILE="$PROJECT_ROOT/.env"
|
||||||
|
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "❌ $ENV_FILE not found — cannot configure release signing." >&2
|
||||||
|
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
|
||||||
|
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Load .env without leaking it into the caller's environment beyond what we need.
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
|
||||||
|
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
|
||||||
|
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
|
||||||
|
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
|
||||||
|
|
||||||
|
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
|
||||||
|
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$PROPS")"
|
||||||
|
umask 077
|
||||||
|
cat > "$PROPS" <<EOF
|
||||||
|
storeFile=$ANDROID_KEYSTORE_FILE
|
||||||
|
storePassword=$ANDROID_KEYSTORE_PASSWORD
|
||||||
|
keyAlias=$ANDROID_KEY_ALIAS
|
||||||
|
keyPassword=$ANDROID_KEY_PASSWORD
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
|
||||||
@@ -1,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"
|
|
||||||
|
|
||||||
@@ -2028,6 +2028,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rusqlite",
|
"tokio-rusqlite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4959,6 +4960,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -5281,7 +5288,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ rand = "0.8"
|
|||||||
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt-multi-thread", "time", "fs", "io-util", "macros"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# JellyTau custom keep rules.
|
||||||
|
#
|
||||||
|
# These classes are loaded by name from the Rust backend via JNI
|
||||||
|
# (env.find_class / class-loader lookups), so R8 cannot see the
|
||||||
|
# references and would otherwise strip or rename them in a minified
|
||||||
|
# release build — causing an instant ClassNotFoundException crash on
|
||||||
|
# startup. See src-tauri/src/player/android/mod.rs and
|
||||||
|
# src-tauri/src/credentials.rs.
|
||||||
|
-keep class com.dtourolle.jellytau.player.** { *; }
|
||||||
|
-keep class com.dtourolle.jellytau.security.** { *; }
|
||||||
|
|
||||||
|
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
|
||||||
|
-keep class androidx.media3.** { *; }
|
||||||
|
-dontwarn androidx.media3.**
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package com.dtourolle.jellytau
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import com.dtourolle.jellytau.player.JellyTauPlayer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the video SurfaceView overlay in the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* This class handles attaching and detaching the native ExoPlayer SurfaceView
|
||||||
|
* so that it renders video content behind the WebView.
|
||||||
|
*/
|
||||||
|
object VideoOverlayManager {
|
||||||
|
|
||||||
|
private var attachedSurfaceView: SurfaceView? = null
|
||||||
|
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
|
||||||
|
private var listenerContentView: ViewGroup? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the video SurfaceView to the Activity's content view.
|
||||||
|
*
|
||||||
|
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
|
||||||
|
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to attach the surface to
|
||||||
|
*/
|
||||||
|
fun attachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
// Get the SurfaceView from JellyTauPlayer
|
||||||
|
val player = JellyTauPlayer.getInstance()
|
||||||
|
val surfaceView = player.getSurfaceView()
|
||||||
|
|
||||||
|
if (surfaceView == null) {
|
||||||
|
android.util.Log.w("VideoOverlayManager", "No SurfaceView available to attach")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the root content view
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
|
||||||
|
// Remove from parent if already attached elsewhere
|
||||||
|
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
|
||||||
|
|
||||||
|
// Configure layout params to fill the screen
|
||||||
|
val layoutParams = FrameLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to content view at index 0 (behind WebView)
|
||||||
|
contentView.addView(surfaceView, 0, layoutParams)
|
||||||
|
attachedSurfaceView = surfaceView
|
||||||
|
|
||||||
|
// Re-fit the video whenever the content view's bounds change (e.g. on
|
||||||
|
// device rotation) so the video is letterboxed to fit instead of being
|
||||||
|
// stretched/cropped by the MATCH_PARENT surface.
|
||||||
|
removeLayoutListener()
|
||||||
|
val listener = android.view.View.OnLayoutChangeListener {
|
||||||
|
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||||
|
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
|
||||||
|
player.fitSurfaceToScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentView.addOnLayoutChangeListener(listener)
|
||||||
|
contentLayoutListener = listener
|
||||||
|
listenerContentView = contentView
|
||||||
|
|
||||||
|
// Fit once now that the surface is attached and the parent is sized.
|
||||||
|
player.fitSurfaceToScreen()
|
||||||
|
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the video SurfaceView from the Activity's view hierarchy.
|
||||||
|
*
|
||||||
|
* @param activity The Activity to detach the surface from
|
||||||
|
*/
|
||||||
|
fun detachVideoSurface(activity: Activity) {
|
||||||
|
try {
|
||||||
|
removeLayoutListener()
|
||||||
|
attachedSurfaceView?.let { surfaceView ->
|
||||||
|
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
contentView.removeView(surfaceView)
|
||||||
|
attachedSurfaceView = null
|
||||||
|
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("VideoOverlayManager", "Failed to detach video surface", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a video surface is currently attached.
|
||||||
|
*
|
||||||
|
* @return true if a surface is attached, false otherwise
|
||||||
|
*/
|
||||||
|
fun isVideoSurfaceAttached(): Boolean {
|
||||||
|
return attachedSurfaceView != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeLayoutListener() {
|
||||||
|
contentLayoutListener?.let { listener ->
|
||||||
|
listenerContentView?.removeOnLayoutChangeListener(listener)
|
||||||
|
}
|
||||||
|
contentLayoutListener = null
|
||||||
|
listenerContentView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,48 +87,39 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
val jellyTauPlayer = JellyTauPlayer.getInstance()
|
||||||
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
val exoPlayer = jellyTauPlayer.getExoPlayer()
|
||||||
|
|
||||||
// Wrap the ExoPlayer to intercept commands
|
// Wrap the ExoPlayer to intercept commands from Media3 controllers
|
||||||
|
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
|
||||||
|
// session rather than the MediaSessionCompat).
|
||||||
|
//
|
||||||
|
// We do NOT execute on ExoPlayer directly here. Every transport command
|
||||||
|
// is routed to Rust via nativeOnMediaCommand, which is the single decision
|
||||||
|
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
|
||||||
|
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
|
||||||
|
// would double-handle local commands and incorrectly drive the local
|
||||||
|
// player while casting.
|
||||||
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
|
||||||
override fun play() {
|
override fun play() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.play()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("play")
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.pause()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("pause")
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToNext() {
|
override fun seekToNext() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToNext()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("next")
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekToPrevious() {
|
override fun seekToPrevious() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekToPrevious()
|
|
||||||
// Then notify Rust for queue management
|
|
||||||
nativeOnMediaCommand("previous")
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.seekTo(positionMs)
|
|
||||||
// Then notify Rust of seek
|
|
||||||
val positionSeconds = positionMs / 1000.0
|
val positionSeconds = positionMs / 1000.0
|
||||||
nativeOnMediaCommand("seek:$positionSeconds")
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
// Execute immediately for instant lockscreen response
|
|
||||||
super.stop()
|
|
||||||
// Then notify Rust for state management
|
|
||||||
nativeOnMediaCommand("stop")
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,36 +151,44 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
)
|
)
|
||||||
isActive = true
|
isActive = true
|
||||||
|
|
||||||
// Set callback to handle lock screen button presses
|
// Set callback to handle lock screen button presses.
|
||||||
|
//
|
||||||
|
// All transport commands are routed through Rust via nativeOnMediaCommand
|
||||||
|
// rather than directly to ExoPlayer. Rust is the single decision point:
|
||||||
|
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
|
||||||
|
// the command to the remote Jellyfin session. This keeps the lockscreen
|
||||||
|
// working identically for both, and avoids the ExoPlayer-only behaviour
|
||||||
|
// that left remote playback uncontrollable from the lockscreen.
|
||||||
setCallback(object : MediaSessionCompat.Callback() {
|
setCallback(object : MediaSessionCompat.Callback() {
|
||||||
override fun onPlay() {
|
override fun onPlay() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
|
||||||
wrappedPlayer?.play()
|
nativeOnMediaCommand("play")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
|
||||||
wrappedPlayer?.pause()
|
nativeOnMediaCommand("pause")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
override fun onSkipToNext() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
|
||||||
wrappedPlayer?.seekToNext()
|
nativeOnMediaCommand("next")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
override fun onSkipToPrevious() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
|
||||||
wrappedPlayer?.seekToPrevious()
|
nativeOnMediaCommand("previous")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
|
||||||
wrappedPlayer?.stop()
|
nativeOnMediaCommand("stop")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(position: Long) {
|
override fun onSeekTo(position: Long) {
|
||||||
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
|
||||||
wrappedPlayer?.seekTo(position)
|
val positionSeconds = position / 1000.0
|
||||||
|
nativeOnMediaCommand("seek:$positionSeconds")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,9 +252,19 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last-known metadata/state, retained so lightweight position ticks can
|
||||||
|
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
|
||||||
|
// and notification. Kept in sync by updateMediaMetadata().
|
||||||
|
private var lastTitle: String = ""
|
||||||
|
private var lastArtist: String = ""
|
||||||
|
private var lastIsPlaying: Boolean = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the MediaSession metadata and playback state.
|
* Update the MediaSession metadata and playback state, plus the notification.
|
||||||
* This updates both the MediaSession and the notification.
|
*
|
||||||
|
* Call this when the track or play/pause state changes. For frequent position
|
||||||
|
* updates during playback, use [updatePlaybackPosition] instead, which is much
|
||||||
|
* cheaper (no metadata rebuild, no notification rebuild).
|
||||||
*/
|
*/
|
||||||
fun updateMediaMetadata(
|
fun updateMediaMetadata(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -267,6 +276,10 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
) {
|
) {
|
||||||
val session = mediaSessionCompat ?: return
|
val session = mediaSessionCompat ?: return
|
||||||
|
|
||||||
|
lastTitle = title
|
||||||
|
lastArtist = artist
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
|
||||||
// Update MediaSession metadata
|
// Update MediaSession metadata
|
||||||
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
|
||||||
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||||
@@ -280,7 +293,59 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
session.setMetadata(metadataBuilder.build())
|
session.setMetadata(metadataBuilder.build())
|
||||||
|
|
||||||
// Update MediaSession playback state
|
// Update MediaSession playback state
|
||||||
val stateBuilder = PlaybackStateCompat.Builder()
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
|
|
||||||
|
// While casting, re-assert the remote volume provider. Metadata pushes
|
||||||
|
// arrive on the session poller thread and can race with (or arrive
|
||||||
|
// before) enableRemoteVolume(); this keeps the session routed to the
|
||||||
|
// remote (absolute) volume slider instead of the local media stream.
|
||||||
|
if (isRemoteVolumeEnabled) {
|
||||||
|
volumeProvider?.let { session.setPlaybackToRemote(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the notification
|
||||||
|
updateNotification(title, artist, isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update only the playback position (and play/pause state) on the MediaSession.
|
||||||
|
*
|
||||||
|
* This is the cheap path used for the periodic (250ms) position ticks: it
|
||||||
|
* refreshes the lockscreen scrubber without rebuilding metadata or the
|
||||||
|
* notification. Without this, the lockscreen scrubber freezes at the position
|
||||||
|
* from the last play/pause and drifts out of sync with actual playback.
|
||||||
|
*
|
||||||
|
* @param position Position in milliseconds
|
||||||
|
* @param isPlaying Whether playback is currently active
|
||||||
|
*/
|
||||||
|
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
|
||||||
|
val session = mediaSessionCompat ?: return
|
||||||
|
val notificationStateChanged = isPlaying != lastIsPlaying
|
||||||
|
lastIsPlaying = isPlaying
|
||||||
|
session.setPlaybackState(buildPlaybackState(isPlaying, position))
|
||||||
|
// Only rebuild the notification when the play/pause icon actually flips.
|
||||||
|
if (notificationStateChanged) {
|
||||||
|
updateNotification(lastTitle, lastArtist, isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a PlaybackStateCompat with the standard transport actions.
|
||||||
|
*
|
||||||
|
* The reported playback speed is 1.0 while playing and 0.0 while paused so
|
||||||
|
* Android does not extrapolate the position past a paused track.
|
||||||
|
*
|
||||||
|
* While remote volume control is enabled (casting), the state is forced to
|
||||||
|
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
|
||||||
|
* (absolute) volume slider for a session that is actively playing; if a
|
||||||
|
* periodic metadata/position push reports paused (e.g. before the remote
|
||||||
|
* session has actually started), reporting STATE_PAUSED here makes the
|
||||||
|
* system tear down the remote slider set up by setPlaybackToRemote() and
|
||||||
|
* fall back to the local media-stream volume.
|
||||||
|
*/
|
||||||
|
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
|
||||||
|
val playing = isPlaying || isRemoteVolumeEnabled
|
||||||
|
return PlaybackStateCompat.Builder()
|
||||||
.setActions(
|
.setActions(
|
||||||
PlaybackStateCompat.ACTION_PLAY or
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
PlaybackStateCompat.ACTION_PAUSE or
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
@@ -290,15 +355,11 @@ class JellyTauPlaybackService : MediaSessionService() {
|
|||||||
PlaybackStateCompat.ACTION_SEEK_TO
|
PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
)
|
)
|
||||||
.setState(
|
.setState(
|
||||||
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||||
position,
|
position,
|
||||||
1.0f
|
if (playing) 1.0f else 0.0f
|
||||||
)
|
)
|
||||||
|
.build()
|
||||||
session.setPlaybackState(stateBuilder.build())
|
|
||||||
|
|
||||||
// Update the notification
|
|
||||||
updateNotification(title, artist, isPlaying)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Current media ID being played */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards against nativeOnPlaybackEnded() firing more than once per loaded
|
||||||
|
* media. ExoPlayer can re-enter STATE_ENDED (e.g. transient buffering near
|
||||||
|
* end of a transcoded stream), which would otherwise notify the backend
|
||||||
|
* twice and, for example, decrement the sleep-timer episode counter twice.
|
||||||
|
* Reset whenever new media is loaded.
|
||||||
|
*/
|
||||||
|
private var endedNotified = false
|
||||||
|
|
||||||
/** Current media metadata for notification updates */
|
/** Current media metadata for notification updates */
|
||||||
private var currentTitle: String = ""
|
private var currentTitle: String = ""
|
||||||
private var currentArtist: String = ""
|
private var currentArtist: String = ""
|
||||||
@@ -152,6 +161,9 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** SurfaceView for video playback */
|
/** SurfaceView for video playback */
|
||||||
private var surfaceView: SurfaceView? = null
|
private var surfaceView: SurfaceView? = null
|
||||||
private var surfaceHolder: SurfaceHolder? = null
|
private var surfaceHolder: SurfaceHolder? = null
|
||||||
|
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
|
||||||
|
private var videoWidth: Int = 0
|
||||||
|
private var videoHeight: Int = 0
|
||||||
private var currentMediaType: MediaType = MediaType.AUDIO
|
private var currentMediaType: MediaType = MediaType.AUDIO
|
||||||
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
|
||||||
|
|
||||||
@@ -171,6 +183,12 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Create ExoPlayer with audio focus handling
|
// Create ExoPlayer with audio focus handling
|
||||||
exoPlayer = ExoPlayer.Builder(appContext)
|
exoPlayer = ExoPlayer.Builder(appContext)
|
||||||
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
.setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
|
||||||
|
// Pause when the audio output is removed (wired headphones unplugged or
|
||||||
|
// Bluetooth device disconnected). ExoPlayer listens for the system
|
||||||
|
// ACTION_AUDIO_BECOMING_NOISY broadcast, which fires for both cases.
|
||||||
|
// The resulting pause flows through onIsPlayingChanged, keeping Rust and
|
||||||
|
// the lockscreen notification in sync automatically.
|
||||||
|
.setHandleAudioBecomingNoisy(true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Set up player listener
|
// Set up player listener
|
||||||
@@ -202,7 +220,15 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
// Playback completed
|
// Playback completed
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended")
|
||||||
stopPositionUpdates()
|
stopPositionUpdates()
|
||||||
|
// Only notify the backend once per loaded media. ExoPlayer
|
||||||
|
// can re-enter STATE_ENDED, which would double-count things
|
||||||
|
// like the sleep-timer episode counter.
|
||||||
|
if (!endedNotified) {
|
||||||
|
endedNotified = true
|
||||||
nativeOnPlaybackEnded()
|
nativeOnPlaybackEnded()
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("JellyTauPlayer", "▶ Playback ended already notified - ignoring")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Player.STATE_BUFFERING -> {
|
Player.STATE_BUFFERING -> {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
android.util.Log.d("JellyTauPlayer", "▶ Buffering...")
|
||||||
@@ -239,7 +265,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}")
|
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
|
||||||
|
// Apply pixel aspect ratio so anamorphic content isn't distorted
|
||||||
|
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
|
||||||
|
videoHeight = videoSize.height
|
||||||
|
fitSurfaceToScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRenderedFirstFrame() {
|
override fun onRenderedFirstFrame() {
|
||||||
@@ -316,6 +346,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
fun load(url: String, mediaId: String) {
|
fun load(url: String, mediaId: String) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
val mediaItem = MediaItem.fromUri(url)
|
||||||
exoPlayer.setMediaItem(mediaItem)
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
@@ -546,6 +577,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
) {
|
) {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
currentMediaId = mediaId
|
currentMediaId = mediaId
|
||||||
|
endedNotified = false
|
||||||
|
|
||||||
// Store metadata for notification updates
|
// Store metadata for notification updates
|
||||||
currentTitle = title
|
currentTitle = title
|
||||||
@@ -735,10 +767,16 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
if (exoPlayer.isPlaying) {
|
if (exoPlayer.isPlaying) {
|
||||||
val position = exoPlayer.currentPosition / 1000.0
|
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0)
|
||||||
|
val position = positionMs / 1000.0
|
||||||
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
|
||||||
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
|
||||||
nativeOnPositionUpdate(position, duration)
|
nativeOnPositionUpdate(position, duration)
|
||||||
|
|
||||||
|
// Keep the lockscreen scrubber live. Without this the
|
||||||
|
// MediaSession position only refreshes on play/pause, so the
|
||||||
|
// scrubber freezes mid-track and drifts out of sync.
|
||||||
|
JellyTauPlaybackService.getInstance()?.updatePlaybackPosition(positionMs, true)
|
||||||
}
|
}
|
||||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
@@ -842,17 +880,62 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resize the video surface (for orientation changes).
|
* Resize the video surface (for orientation changes).
|
||||||
|
*
|
||||||
|
* Re-fits the surface to the screen preserving the video's aspect ratio so
|
||||||
|
* nothing is cropped when the device rotates.
|
||||||
*/
|
*/
|
||||||
fun resizeSurface(width: Int, height: Int) {
|
fun resizeSurface(width: Int, height: Int) {
|
||||||
|
fitSurfaceToScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size the video SurfaceView so the video fits entirely inside its parent
|
||||||
|
* (the full-screen content view) while preserving aspect ratio (letterbox/
|
||||||
|
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
|
||||||
|
* video to the surface bounds, which crops the bottom on rotation.
|
||||||
|
*/
|
||||||
|
fun fitSurfaceToScreen() {
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
surfaceView?.let { view ->
|
val view = surfaceView ?: return@post
|
||||||
view.layoutParams = view.layoutParams.apply {
|
val parent = view.parent as? ViewGroup
|
||||||
this.width = width
|
// Available area: prefer the parent's measured size, fall back to the screen.
|
||||||
this.height = height
|
val availW = parent?.width?.takeIf { it > 0 }
|
||||||
|
?: appContext.resources.displayMetrics.widthPixels
|
||||||
|
val availH = parent?.height?.takeIf { it > 0 }
|
||||||
|
?: appContext.resources.displayMetrics.heightPixels
|
||||||
|
|
||||||
|
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
|
||||||
|
return@post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
|
||||||
|
val viewAspect = availW.toFloat() / availH.toFloat()
|
||||||
|
|
||||||
|
val targetW: Int
|
||||||
|
val targetH: Int
|
||||||
|
if (videoAspect > viewAspect) {
|
||||||
|
// Video is wider than the screen → fit width, letterbox top/bottom
|
||||||
|
targetW = availW
|
||||||
|
targetH = (availW / videoAspect).toInt()
|
||||||
|
} else {
|
||||||
|
// Video is taller than the screen → fit height, pillarbox sides
|
||||||
|
targetH = availH
|
||||||
|
targetW = (availH * videoAspect).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val lp = view.layoutParams
|
||||||
|
// FrameLayout child: center the fitted surface within the full-screen parent.
|
||||||
|
if (lp is FrameLayout.LayoutParams) {
|
||||||
|
lp.gravity = android.view.Gravity.CENTER
|
||||||
|
}
|
||||||
|
lp.width = targetW
|
||||||
|
lp.height = targetH
|
||||||
|
view.layoutParams = lp
|
||||||
view.requestLayout()
|
view.requestLayout()
|
||||||
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
|
android.util.Log.d(
|
||||||
}
|
"JellyTauPlayer",
|
||||||
|
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 870 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 580 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -10,8 +10,9 @@ use crate::connectivity::ConnectivityMonitor;
|
|||||||
pub use session_verifier::SessionVerifier;
|
pub use session_verifier::SessionVerifier;
|
||||||
|
|
||||||
/// Server information returned from Jellyfin
|
/// Server information returned from Jellyfin
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[specta(rename = "AuthServerInfo")]
|
||||||
pub struct ServerInfo {
|
pub struct ServerInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
@@ -21,7 +22,7 @@ pub struct ServerInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User information
|
/// User information
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -31,7 +32,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication result
|
/// Authentication result
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AuthResult {
|
pub struct AuthResult {
|
||||||
pub user: User,
|
pub user: User,
|
||||||
@@ -40,7 +41,7 @@ pub struct AuthResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Active session for restoration
|
/// Active session for restoration
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
@@ -55,7 +56,7 @@ pub struct Session {
|
|||||||
|
|
||||||
// Jellyfin API response types (PascalCase from server)
|
// Jellyfin API response types (PascalCase from server)
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct PublicSystemInfo {
|
struct PublicSystemInfo {
|
||||||
server_name: String,
|
server_name: String,
|
||||||
@@ -63,7 +64,7 @@ struct PublicSystemInfo {
|
|||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct AuthenticateByNameResponse {
|
struct AuthenticateByNameResponse {
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
@@ -71,7 +72,7 @@ struct AuthenticateByNameResponse {
|
|||||||
server_id: String,
|
server_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
struct JellyfinUser {
|
struct JellyfinUser {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -132,7 +133,7 @@ impl AuthManager {
|
|||||||
|
|
||||||
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
log::info!("[AuthManager] Connecting to server: {}", normalized_url);
|
||||||
|
|
||||||
match self.http_client.get_json_with_retry::<PublicSystemInfo>(&endpoint).await {
|
match self.http_client.get_json_fast::<PublicSystemInfo>(&endpoint).await {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
log::info!("[AuthManager] Connected to server: {} ({})", info.server_name, info.version);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub struct SessionVerifierWrapper(pub Arc<tokio::sync::Mutex<Option<SessionVerif
|
|||||||
/// Initialize the auth manager (call on app startup)
|
/// Initialize the auth manager (call on app startup)
|
||||||
/// Restores session from storage if available
|
/// Restores session from storage if available
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_initialize(
|
pub async fn auth_initialize(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
database: State<'_, crate::commands::DatabaseWrapper>,
|
database: State<'_, crate::commands::DatabaseWrapper>,
|
||||||
@@ -61,6 +62,7 @@ pub async fn auth_initialize(
|
|||||||
|
|
||||||
/// Connect to a Jellyfin server and get server info
|
/// Connect to a Jellyfin server and get server info
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_connect_to_server(
|
pub async fn auth_connect_to_server(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
@@ -70,6 +72,7 @@ pub async fn auth_connect_to_server(
|
|||||||
|
|
||||||
/// Login with username and password
|
/// Login with username and password
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_login(
|
pub async fn auth_login(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
username: String,
|
username: String,
|
||||||
@@ -100,6 +103,7 @@ pub async fn auth_login(
|
|||||||
|
|
||||||
/// Verify current session
|
/// Verify current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_verify_session(
|
pub async fn auth_verify_session(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -118,6 +122,7 @@ pub async fn auth_verify_session(
|
|||||||
|
|
||||||
/// Logout (clear session and call Jellyfin logout endpoint)
|
/// Logout (clear session and call Jellyfin logout endpoint)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_logout(
|
pub async fn auth_logout(
|
||||||
server_url: String,
|
server_url: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
@@ -143,6 +148,7 @@ pub async fn auth_logout(
|
|||||||
|
|
||||||
/// Get current session
|
/// Get current session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_get_session(
|
pub async fn auth_get_session(
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
) -> Result<Option<Session>, String> {
|
) -> Result<Option<Session>, String> {
|
||||||
@@ -151,6 +157,7 @@ pub async fn auth_get_session(
|
|||||||
|
|
||||||
/// Set current session (for restoration from storage)
|
/// Set current session (for restoration from storage)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_set_session(
|
pub async fn auth_set_session(
|
||||||
session: Option<Session>,
|
session: Option<Session>,
|
||||||
auth_manager: State<'_, AuthManagerWrapper>,
|
auth_manager: State<'_, AuthManagerWrapper>,
|
||||||
@@ -170,6 +177,7 @@ pub async fn auth_set_session(
|
|||||||
|
|
||||||
/// Start background session verification
|
/// Start background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_start_verification(
|
pub async fn auth_start_verification(
|
||||||
device_id: String,
|
device_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -198,6 +206,7 @@ pub async fn auth_start_verification(
|
|||||||
|
|
||||||
/// Stop background session verification
|
/// Stop background session verification
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_stop_verification(
|
pub async fn auth_stop_verification(
|
||||||
session_verifier: State<'_, SessionVerifierWrapper>,
|
session_verifier: State<'_, SessionVerifierWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -212,6 +221,7 @@ pub async fn auth_stop_verification(
|
|||||||
|
|
||||||
/// Re-authenticate with password (when session expired)
|
/// Re-authenticate with password (when session expired)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn auth_reauthenticate(
|
pub async fn auth_reauthenticate(
|
||||||
password: String,
|
password: String,
|
||||||
device_id: String,
|
device_id: String,
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Tauri commands for the offline "browse & queue" feature.
|
||||||
|
//!
|
||||||
|
//! Two backend pieces support browsing the full server catalog while offline
|
||||||
|
//! and queueing downloads that fire on reconnect:
|
||||||
|
//!
|
||||||
|
//! - [`sync_full_catalog`] walks every library while online and persists all
|
||||||
|
//! items to the offline cache so the whole catalog is browsable (greyed out)
|
||||||
|
//! offline. It reuses [`HybridRepository::cache_items_from_server`], which in
|
||||||
|
//! turn reuses `OfflineRepository::save_to_cache` (sets `synced_at`, which is
|
||||||
|
//! what `get_items` branch 3 serves offline).
|
||||||
|
//! - [`resume_queued_downloads`] resolves and pumps the `pending` download rows
|
||||||
|
//! that were queued offline (they have `stream_url IS NULL`), mirroring the
|
||||||
|
//! heal-and-pump pattern in `player_preload_upcoming`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::commands::repository::RepositoryManagerWrapper;
|
||||||
|
use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
|
||||||
|
use crate::commands::storage::DatabaseWrapper;
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
|
/// app_settings key holding the RFC-3339 timestamp of the last successful
|
||||||
|
/// full-catalog sync.
|
||||||
|
const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
|
||||||
|
|
||||||
|
/// Item types worth caching for offline browsing: containers the library
|
||||||
|
/// landing pages render plus the playable leaves users queue for download.
|
||||||
|
const CATALOG_ITEM_TYPES: &[&str] = &[
|
||||||
|
"MusicAlbum",
|
||||||
|
"Movie",
|
||||||
|
"Series",
|
||||||
|
"Season",
|
||||||
|
"Episode",
|
||||||
|
"Audio",
|
||||||
|
"BoxSet",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncResult {
|
||||||
|
/// Total items persisted to the offline cache across all libraries.
|
||||||
|
pub items_cached: usize,
|
||||||
|
/// Libraries that failed to sync (e.g. server hiccup); best-effort.
|
||||||
|
pub libraries_failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogSyncStatus {
|
||||||
|
/// RFC-3339 timestamp of the last successful sync, if any.
|
||||||
|
pub last_synced_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk every library on the server and persist all items to the offline cache
|
||||||
|
/// so the full catalog is browsable offline (greyed out when not downloaded).
|
||||||
|
///
|
||||||
|
/// Best-effort: a library that fails to fetch is counted and skipped rather than
|
||||||
|
/// aborting the whole sync. Runs libraries sequentially to avoid hammering the
|
||||||
|
/// server. Uses `Recursive=true` so a single request per library returns the
|
||||||
|
/// containers and their playable children.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn sync_full_catalog(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<CatalogSyncResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
|
||||||
|
info!("[Catalog] Full sync starting across {} libraries", libraries.len());
|
||||||
|
|
||||||
|
let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let mut items_cached = 0usize;
|
||||||
|
let mut libraries_failed = 0usize;
|
||||||
|
|
||||||
|
for library in &libraries {
|
||||||
|
let opts = GetItemsOptions {
|
||||||
|
recursive: Some(true),
|
||||||
|
include_item_types: Some(include_types.clone()),
|
||||||
|
limit: Some(100_000),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match repo.cache_items_from_server(&library.id, Some(opts)).await {
|
||||||
|
Ok(items) => {
|
||||||
|
info!(
|
||||||
|
"[Catalog] Cached {} items from library '{}'",
|
||||||
|
items.len(),
|
||||||
|
library.name
|
||||||
|
);
|
||||||
|
items_cached += items.len();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Catalog] Failed to sync library '{}': {:?}", library.name, e);
|
||||||
|
libraries_failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the sync time so callers can skip re-syncing too eagerly.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let upsert = Query::with_params(
|
||||||
|
"INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
|
||||||
|
QueryParam::String(now),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(upsert).await {
|
||||||
|
warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[Catalog] Full sync complete: {} items cached, {} libraries failed",
|
||||||
|
items_cached, libraries_failed
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(CatalogSyncResult {
|
||||||
|
items_cached,
|
||||||
|
libraries_failed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Report the last-synced timestamp so the UI can show a hint / decide whether
|
||||||
|
/// to trigger a fresh sync.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn catalog_sync_status(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
) -> Result<CatalogSyncStatus, 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 value FROM app_settings WHERE key = ?",
|
||||||
|
vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
|
||||||
|
);
|
||||||
|
let last_synced_at: Option<String> = db_service
|
||||||
|
.query_optional(query, |row| row.get(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(CatalogSyncStatus { last_synced_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control whether offline library queries reveal the full synced catalog
|
||||||
|
/// (greyed-out, non-downloaded media) or only downloaded/local media.
|
||||||
|
///
|
||||||
|
/// The frontend calls this from the "Show all server media" toggle: pass `true`
|
||||||
|
/// when online, or when offline with the toggle on; pass `false` when offline
|
||||||
|
/// with the toggle off so library pages show downloaded media only. Fixes the
|
||||||
|
/// bug where offline library pages showed every server item regardless of the
|
||||||
|
/// toggle.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub fn set_show_server_catalog(show: bool) {
|
||||||
|
crate::repository::offline::set_include_catalog_browse(show);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResumeQueuedResult {
|
||||||
|
/// Rows whose stream URL was resolved and are now pump-eligible.
|
||||||
|
pub resolved: usize,
|
||||||
|
/// Rows that couldn't be resolved (item metadata / URL lookup failed).
|
||||||
|
pub failed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of [`resume_queued_downloads`], factored out for testing: select every
|
||||||
|
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
|
||||||
|
/// `None` leaves the row pending), and heal the row so the pump can start it.
|
||||||
|
/// The `resolve` closure receives `(item_id, media_type, quality_preset)`.
|
||||||
|
pub(crate) async fn resolve_pending_download_urls<F, Fut>(
|
||||||
|
db_service: &Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
target_dir: &str,
|
||||||
|
resolve: F,
|
||||||
|
) -> Result<ResumeQueuedResult, String>
|
||||||
|
where
|
||||||
|
F: Fn(String, String, String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Option<String>>,
|
||||||
|
{
|
||||||
|
let rows_query = Query::new(
|
||||||
|
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
|
||||||
|
FROM downloads
|
||||||
|
WHERE status = 'pending' AND stream_url IS NULL",
|
||||||
|
);
|
||||||
|
let rows: Vec<(i64, String, String, String)> = db_service
|
||||||
|
.query_many(rows_query, |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
return Ok(ResumeQueuedResult { resolved: 0, failed: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[Catalog] Resolving {} offline-queued downloads on reconnect", rows.len());
|
||||||
|
|
||||||
|
let mut resolved = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for (download_id, item_id, media_type, quality) in rows {
|
||||||
|
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
|
||||||
|
Some(url) => url,
|
||||||
|
None => {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
|
||||||
|
// concurrent resolver doesn't clobber an already-started row.
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET stream_url = ?, target_dir = ?
|
||||||
|
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir.to_string()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
match db_service.execute(update).await {
|
||||||
|
Ok(n) if n > 0 => resolved += 1,
|
||||||
|
Ok(_) => {} // already resolved by someone else; not a failure
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Catalog] Failed to persist URL for download {}: {}", download_id, e);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the stream URL for every download row that was queued while offline
|
||||||
|
/// (`status = 'pending' AND stream_url IS NULL`), then pump the queue so they
|
||||||
|
/// start. Call this on reconnect.
|
||||||
|
///
|
||||||
|
/// Audio rows resolve via `get_audio_stream_url`; video rows (media_type =
|
||||||
|
/// 'video') via the pure `get_video_download_url` builder using the row's stored
|
||||||
|
/// `quality_preset` — mirroring `enqueue_video_downloads`. Rows whose URL can't
|
||||||
|
/// be resolved are left pending (they retry on the next reconnect).
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn resume_queued_downloads(
|
||||||
|
repository: State<'_, RepositoryManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
handle: String,
|
||||||
|
) -> Result<ResumeQueuedResult, String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
use crate::repository::HybridRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
// The pump needs a target_dir; use the same storage root the other download
|
||||||
|
// paths use (the database's parent directory — see `storage_get_path`).
|
||||||
|
let (db_service, target_dir) = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
let target_dir = database
|
||||||
|
.path()
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
(Arc::new(database.service()), target_dir)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recover stale downloads: rows left in 'downloading' when the app was killed
|
||||||
|
// mid-transfer are orphaned — nothing ever restarts them, so they show as
|
||||||
|
// permanently "downloading". Reset them to 'pending' and clear the stale
|
||||||
|
// stream_url so they get re-resolved and restarted from scratch below.
|
||||||
|
let recover_query = Query::new(
|
||||||
|
"UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
|
||||||
|
bytes_downloaded = 0, started_at = NULL \
|
||||||
|
WHERE status = 'downloading'",
|
||||||
|
);
|
||||||
|
match db_service.execute(recover_query).await {
|
||||||
|
Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve each row's URL against the (now reachable) repository.
|
||||||
|
let repo_for_resolve = Arc::clone(&repo);
|
||||||
|
let outcome = resolve_pending_download_urls(
|
||||||
|
&db_service,
|
||||||
|
&target_dir,
|
||||||
|
move |item_id: String, media_type: String, quality: String| {
|
||||||
|
let repo = Arc::clone(&repo_for_resolve);
|
||||||
|
async move {
|
||||||
|
if media_type == "video" {
|
||||||
|
Some(<HybridRepository as MediaRepository>::get_video_download_url(
|
||||||
|
repo.as_ref(),
|
||||||
|
&item_id,
|
||||||
|
&quality,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
match repo.get_audio_stream_url(&item_id).await {
|
||||||
|
Ok(url) => Some(url),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[Catalog] Failed to resolve audio URL for {}: {:?}", item_id, e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let ResumeQueuedResult { resolved, failed } = outcome;
|
||||||
|
|
||||||
|
// Kick the pump so the newly-resolved rows actually start.
|
||||||
|
if resolved > 0 {
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("[Catalog] Resume complete: {} resolved, {} failed", resolved, failed);
|
||||||
|
|
||||||
|
Ok(ResumeQueuedResult { resolved, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::db_service::RusqliteService;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
fn test_db() -> Arc<RusqliteService> {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE downloads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
stream_url TEXT,
|
||||||
|
target_dir TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
quality_preset TEXT
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_download(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
item_id: &str,
|
||||||
|
status: &str,
|
||||||
|
stream_url: Option<&str>,
|
||||||
|
media_type: Option<&str>,
|
||||||
|
) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
QueryParam::String(status.to_string()),
|
||||||
|
stream_url.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||||
|
media_type.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db.execute(q).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_row(db: &Arc<RusqliteService>, item_id: &str) -> (String, Option<String>, Option<String>) {
|
||||||
|
let q = Query::with_params(
|
||||||
|
"SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
|
||||||
|
vec![QueryParam::String(item_id.to_string())],
|
||||||
|
);
|
||||||
|
db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||||
|
let db = test_db();
|
||||||
|
// A row queued offline: pending with no URL yet.
|
||||||
|
insert_download(&db, "queued-1", "pending", None, None).await;
|
||||||
|
// An already-resolved pending row: must NOT be touched.
|
||||||
|
insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
|
||||||
|
// A completed row: irrelevant.
|
||||||
|
insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
|
||||||
|
|
||||||
|
let out = resolve_pending_download_urls(&db, "/data/downloads", |item_id, _mt, _q| async move {
|
||||||
|
Some(format!("http://resolved/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
assert_eq!(out.failed, 0);
|
||||||
|
|
||||||
|
// The offline-queued row now has a URL + target dir and stays pending.
|
||||||
|
let (status, url, target) = get_row(&db, "queued-1").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
|
||||||
|
assert_eq!(target.as_deref(), Some("/data/downloads"));
|
||||||
|
|
||||||
|
// The already-resolved row is unchanged (not re-resolved).
|
||||||
|
let (_s, url2, _t) = get_row(&db, "already").await;
|
||||||
|
assert_eq!(url2.as_deref(), Some("http://existing/url"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "bad", "pending", None, None).await;
|
||||||
|
|
||||||
|
// Resolver returns None (e.g. server lookup failed).
|
||||||
|
let out = resolve_pending_download_urls(&db, "/data", |_id, _mt, _q| async move { None })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 0);
|
||||||
|
assert_eq!(out.failed, 1);
|
||||||
|
|
||||||
|
// Still pending with no URL, so a later reconnect can retry it.
|
||||||
|
let (status, url, _t) = get_row(&db, "bad").await;
|
||||||
|
assert_eq!(status, "pending");
|
||||||
|
assert_eq!(url, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn video_rows_use_media_type_in_resolver() {
|
||||||
|
let db = test_db();
|
||||||
|
insert_download(&db, "vid-1", "pending", None, Some("video")).await;
|
||||||
|
|
||||||
|
let out = resolve_pending_download_urls(&db, "/data", |item_id, media_type, _q| async move {
|
||||||
|
assert_eq!(media_type, "video");
|
||||||
|
Some(format!("http://transcode/{item_id}"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.resolved, 1);
|
||||||
|
let (_s, url, _t) = get_row(&db, "vid-1").await;
|
||||||
|
assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ pub struct ConnectivityMonitorWrapper(pub Arc<tokio::sync::Mutex<ConnectivityMon
|
|||||||
|
|
||||||
/// Check if the server is currently reachable
|
/// Check if the server is currently reachable
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_check_server(
|
pub async fn connectivity_check_server(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -16,6 +17,7 @@ pub async fn connectivity_check_server(
|
|||||||
|
|
||||||
/// Set the server URL and trigger an immediate check
|
/// Set the server URL and trigger an immediate check
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_set_server_url(
|
pub async fn connectivity_set_server_url(
|
||||||
url: String,
|
url: String,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -27,6 +29,7 @@ pub async fn connectivity_set_server_url(
|
|||||||
|
|
||||||
/// Get the current connectivity status
|
/// Get the current connectivity status
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_get_status(
|
pub async fn connectivity_get_status(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<ConnectivityStatus, String> {
|
) -> Result<ConnectivityStatus, String> {
|
||||||
@@ -36,6 +39,7 @@ pub async fn connectivity_get_status(
|
|||||||
|
|
||||||
/// Start monitoring connectivity with adaptive polling
|
/// Start monitoring connectivity with adaptive polling
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_start_monitoring(
|
pub async fn connectivity_start_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -46,6 +50,7 @@ pub async fn connectivity_start_monitoring(
|
|||||||
|
|
||||||
/// Stop monitoring connectivity
|
/// Stop monitoring connectivity
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_stop_monitoring(
|
pub async fn connectivity_stop_monitoring(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -56,6 +61,7 @@ pub async fn connectivity_stop_monitoring(
|
|||||||
|
|
||||||
/// Mark the server as reachable (called after successful API calls)
|
/// Mark the server as reachable (called after successful API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_reachable(
|
pub async fn connectivity_mark_reachable(
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -66,6 +72,7 @@ pub async fn connectivity_mark_reachable(
|
|||||||
|
|
||||||
/// Mark the server as unreachable (called after failed API calls)
|
/// Mark the server as unreachable (called after failed API calls)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn connectivity_mark_unreachable(
|
pub async fn connectivity_mark_unreachable(
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
state: State<'_, ConnectivityMonitorWrapper>,
|
state: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::utils::conversions::{
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "3:45" or "12:09"
|
/// Formatted string like "3:45" or "12:09"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds(seconds: f64) -> String {
|
pub fn format_time_seconds(seconds: f64) -> String {
|
||||||
format_time(seconds)
|
format_time(seconds)
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ pub fn format_time_seconds(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Formatted string like "1:23:45" or "3:45"
|
/// Formatted string like "1:23:45" or "3:45"
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn format_time_seconds_long(seconds: f64) -> String {
|
pub fn format_time_seconds_long(seconds: f64) -> String {
|
||||||
format_time_long(seconds)
|
format_time_long(seconds)
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ pub fn format_time_seconds_long(seconds: f64) -> String {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Time in seconds
|
/// Time in seconds
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
||||||
ticks_to_seconds(ticks)
|
ticks_to_seconds(ticks)
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ pub fn convert_ticks_to_seconds(ticks: i64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Progress as percentage (0.0 to 100.0)
|
/// Progress as percentage (0.0 to 100.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
||||||
calculate_progress(position, duration)
|
calculate_progress(position, duration)
|
||||||
}
|
}
|
||||||
@@ -69,6 +73,7 @@ pub fn calc_progress(position: f64, duration: f64) -> f64 {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// Normalized volume (0.0 to 1.0)
|
/// Normalized volume (0.0 to 1.0)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
pub fn convert_percent_to_volume(percent: f64) -> f64 {
|
||||||
percent_to_volume(percent)
|
percent_to_volume(percent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-009 | DR-011
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -79,6 +80,7 @@ pub async fn device_get_id(db: State<'_, DatabaseWrapper>) -> Result<String, Str
|
|||||||
///
|
///
|
||||||
/// TRACES: UR-009 | DR-011
|
/// TRACES: UR-009 | DR-011
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
pub async fn device_set_id(device_id: String, db: State<'_, DatabaseWrapper>) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::State;
|
use tauri::{Manager, State};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::download::{DownloadInfo, DownloadManager};
|
use crate::download::{DownloadInfo, DownloadManager};
|
||||||
@@ -22,7 +23,7 @@ pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
|||||||
|
|
||||||
/// Download statistics computed server-side
|
/// Download statistics computed server-side
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadStats {
|
pub struct DownloadStats {
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
@@ -35,7 +36,7 @@ pub struct DownloadStats {
|
|||||||
|
|
||||||
/// Enhanced response with pre-computed stats
|
/// Enhanced response with pre-computed stats
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DownloadsResponse {
|
pub struct DownloadsResponse {
|
||||||
pub downloads: Vec<DownloadInfo>,
|
pub downloads: Vec<DownloadInfo>,
|
||||||
@@ -52,22 +53,66 @@ fn sanitize_filename(name: &str) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||||
|
/// 10-argument command limit).
|
||||||
|
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadItemAndStartRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub stream_url: String,
|
||||||
|
pub target_dir: String,
|
||||||
|
pub item_name: Option<String>,
|
||||||
|
pub artist_name: Option<String>,
|
||||||
|
pub album_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request payload for download_item.
|
||||||
|
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadItemRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub item_name: Option<String>,
|
||||||
|
pub artist_name: Option<String>,
|
||||||
|
pub album_name: Option<String>,
|
||||||
|
pub expected_size: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request payload for download_video.
|
||||||
|
#[derive(Debug, specta::Type, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DownloadVideoRequest {
|
||||||
|
pub item_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub file_path: String,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub priority: Option<i32>,
|
||||||
|
pub item_name: Option<String>,
|
||||||
|
pub quality_preset: Option<String>,
|
||||||
|
pub series_name: Option<String>,
|
||||||
|
pub season_name: Option<String>,
|
||||||
|
pub episode_number: Option<i32>,
|
||||||
|
pub season_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Queue and start a download in a single atomic operation
|
/// Queue and start a download in a single atomic operation
|
||||||
/// This simplifies the frontend flow by combining multiple steps
|
/// This simplifies the frontend flow by combining multiple steps
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_item_and_start(
|
pub async fn download_item_and_start(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
download_manager: State<'_, DownloadManagerWrapper>,
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
item_id: String,
|
request: DownloadItemAndStartRequest,
|
||||||
user_id: String,
|
|
||||||
stream_url: String,
|
|
||||||
target_dir: String,
|
|
||||||
item_name: Option<String>,
|
|
||||||
artist_name: Option<String>,
|
|
||||||
album_name: Option<String>,
|
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
|
let DownloadItemAndStartRequest {
|
||||||
|
item_id, user_id, stream_url, target_dir, item_name, artist_name, album_name,
|
||||||
|
} = request;
|
||||||
// Sanitize filename
|
// Sanitize filename
|
||||||
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
|
let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
|
||||||
let file_path = format!("downloads/{}.mp3", safe_name);
|
let file_path = format!("downloads/{}.mp3", safe_name);
|
||||||
@@ -76,15 +121,17 @@ pub async fn download_item_and_start(
|
|||||||
let download_id = download_item(
|
let download_id = download_item(
|
||||||
db.clone(),
|
db.clone(),
|
||||||
smart_cache.clone(),
|
smart_cache.clone(),
|
||||||
|
DownloadItemRequest {
|
||||||
item_id,
|
item_id,
|
||||||
user_id,
|
user_id,
|
||||||
file_path,
|
file_path,
|
||||||
None, // mime_type
|
mime_type: None,
|
||||||
None, // priority
|
priority: None,
|
||||||
item_name,
|
item_name,
|
||||||
artist_name,
|
artist_name,
|
||||||
album_name,
|
album_name,
|
||||||
None, // expected_size
|
expected_size: None,
|
||||||
|
},
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
// Start the download immediately
|
// Start the download immediately
|
||||||
@@ -102,19 +149,15 @@ pub async fn download_item_and_start(
|
|||||||
|
|
||||||
/// Queue a media item for download
|
/// Queue a media item for download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_item(
|
pub async fn download_item(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
item_id: String,
|
request: DownloadItemRequest,
|
||||||
user_id: String,
|
|
||||||
file_path: String,
|
|
||||||
mime_type: Option<String>,
|
|
||||||
priority: Option<i32>,
|
|
||||||
item_name: Option<String>,
|
|
||||||
artist_name: Option<String>,
|
|
||||||
album_name: Option<String>,
|
|
||||||
expected_size: Option<i64>,
|
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
|
let DownloadItemRequest {
|
||||||
|
item_id, user_id, file_path, mime_type, priority, item_name, artist_name, album_name, expected_size,
|
||||||
|
} = request;
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -196,6 +239,7 @@ pub async fn download_item(
|
|||||||
|
|
||||||
/// Queue an entire album for download
|
/// Queue an entire album for download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_album(
|
pub async fn download_album(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
album_id: String,
|
album_id: String,
|
||||||
@@ -267,21 +311,15 @@ pub async fn download_album(
|
|||||||
|
|
||||||
/// Queue a video item (movie or episode) for download with quality preset
|
/// Queue a video item (movie or episode) for download with quality preset
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_video(
|
pub async fn download_video(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
item_id: String,
|
request: DownloadVideoRequest,
|
||||||
user_id: String,
|
|
||||||
file_path: String,
|
|
||||||
mime_type: Option<String>,
|
|
||||||
priority: Option<i32>,
|
|
||||||
item_name: Option<String>,
|
|
||||||
quality_preset: Option<String>,
|
|
||||||
// Video-specific metadata
|
|
||||||
series_name: Option<String>,
|
|
||||||
season_name: Option<String>,
|
|
||||||
episode_number: Option<i32>,
|
|
||||||
season_number: Option<i32>,
|
|
||||||
) -> Result<i64, String> {
|
) -> Result<i64, String> {
|
||||||
|
let DownloadVideoRequest {
|
||||||
|
item_id, user_id, file_path, mime_type, priority, item_name, quality_preset,
|
||||||
|
series_name, season_name, episode_number, season_number,
|
||||||
|
} = request;
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
Arc::new(database.service())
|
||||||
@@ -338,6 +376,7 @@ pub async fn download_video(
|
|||||||
|
|
||||||
/// Queue all episodes of a series for download
|
/// Queue all episodes of a series for download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_series(
|
pub async fn download_series(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
series_id: String,
|
series_id: String,
|
||||||
@@ -438,6 +477,7 @@ pub async fn download_series(
|
|||||||
|
|
||||||
/// Queue all episodes of a specific season for download
|
/// Queue all episodes of a specific season for download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn download_season(
|
pub async fn download_season(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
season_id: String,
|
season_id: String,
|
||||||
@@ -558,6 +598,7 @@ fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
|
|||||||
|
|
||||||
/// Get all downloads for a user, optionally filtered by status
|
/// Get all downloads for a user, optionally filtered by status
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_downloads(
|
pub async fn get_downloads(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -632,6 +673,7 @@ pub async fn get_downloads(
|
|||||||
|
|
||||||
/// Pause a download
|
/// Pause a download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -649,6 +691,7 @@ pub async fn pause_download(db: State<'_, DatabaseWrapper>, download_id: i64) ->
|
|||||||
|
|
||||||
/// Resume a paused download
|
/// Resume a paused download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -666,6 +709,7 @@ pub async fn resume_download(db: State<'_, DatabaseWrapper>, download_id: i64) -
|
|||||||
|
|
||||||
/// Cancel a download
|
/// Cancel a download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn cancel_download(
|
pub async fn cancel_download(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
download_manager: State<'_, DownloadManagerWrapper>,
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
@@ -714,6 +758,7 @@ pub async fn cancel_download(
|
|||||||
|
|
||||||
/// Mark a download as completed
|
/// Mark a download as completed
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn mark_download_completed(
|
pub async fn mark_download_completed(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
download_id: i64,
|
download_id: i64,
|
||||||
@@ -742,6 +787,7 @@ pub async fn mark_download_completed(
|
|||||||
|
|
||||||
/// Mark a download as failed
|
/// Mark a download as failed
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn mark_download_failed(
|
pub async fn mark_download_failed(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
download_id: i64,
|
download_id: i64,
|
||||||
@@ -764,6 +810,7 @@ pub async fn mark_download_failed(
|
|||||||
/// Start downloading a file immediately
|
/// Start downloading a file immediately
|
||||||
/// This command actually downloads the file using the worker
|
/// This command actually downloads the file using the worker
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn start_download(
|
pub async fn start_download(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
download_manager: State<'_, DownloadManagerWrapper>,
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
@@ -772,9 +819,7 @@ pub async fn start_download(
|
|||||||
stream_url: String,
|
stream_url: String,
|
||||||
target_dir: String,
|
target_dir: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::download::{DownloadTask, DownloadWorker};
|
|
||||||
use crate::download::events::DownloadEvent;
|
use crate::download::events::DownloadEvent;
|
||||||
use std::path::PathBuf;
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
debug!("start_download called for download_id: {}", download_id);
|
debug!("start_download called for download_id: {}", download_id);
|
||||||
@@ -860,16 +905,27 @@ pub async fn start_download(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update status to downloading and save file_size if we got it
|
// Update status to downloading and save file_size if we got it.
|
||||||
|
// Also persist the resolved stream URL + target dir so the queue pump can
|
||||||
|
// restart/resume this download by itself if needed.
|
||||||
let update_query = if let Some(size) = file_size_from_server {
|
let update_query = if let Some(size) = file_size_from_server {
|
||||||
Query::with_params(
|
Query::with_params(
|
||||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ? WHERE id = ?",
|
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
|
||||||
vec![QueryParam::Int64(size), QueryParam::Int64(download_id)],
|
vec![
|
||||||
|
QueryParam::Int64(size),
|
||||||
|
QueryParam::String(stream_url.clone()),
|
||||||
|
QueryParam::String(target_dir.clone()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Query::with_params(
|
Query::with_params(
|
||||||
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
|
||||||
vec![QueryParam::Int64(download_id)],
|
vec![
|
||||||
|
QueryParam::String(stream_url.clone()),
|
||||||
|
QueryParam::String(target_dir.clone()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -890,29 +946,304 @@ pub async fn start_download(
|
|||||||
// Build target path
|
// Build target path
|
||||||
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||||
|
|
||||||
// Create download task
|
|
||||||
let task = DownloadTask {
|
|
||||||
url: stream_url,
|
|
||||||
target_path: target_path.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start download in background
|
|
||||||
let app_clone = app.clone();
|
|
||||||
let item_id_clone = item_id.clone();
|
|
||||||
|
|
||||||
// Get a clone of the active downloads Arc for unregistering later
|
// Get a clone of the active downloads Arc for unregistering later
|
||||||
let active_downloads = {
|
let active_downloads = {
|
||||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
manager.get_active_downloads()
|
manager.get_active_downloads()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Run the worker in the background; on completion/failure it frees the slot
|
||||||
|
// and pumps the next pending download.
|
||||||
|
spawn_download_worker(
|
||||||
|
app.clone(),
|
||||||
|
download_id,
|
||||||
|
item_id,
|
||||||
|
stream_url,
|
||||||
|
target_path,
|
||||||
|
active_downloads,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a download with its resolved stream URL, then let the queue pump
|
||||||
|
/// start it (or a higher-priority pending item) when a slot is free.
|
||||||
|
///
|
||||||
|
/// Unlike [`start_download`], this never errors when the concurrency limit is
|
||||||
|
/// reached: the URL is persisted on the row and the pump will pick it up once a
|
||||||
|
/// slot frees. This is the path bulk operations (album/series/season) use so
|
||||||
|
/// every queued item eventually downloads without the frontend re-issuing it.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn enqueue_download(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
download_id: i64,
|
||||||
|
stream_url: String,
|
||||||
|
target_dir: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist the resolved URL/dir and mark the row pending so the pump can
|
||||||
|
// start it. We don't flip to 'downloading' here — the pump owns that.
|
||||||
|
let update_query = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
db_service.execute(update_query).await.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Kick the pump: it will start as many pending downloads as there are slots.
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enqueue a batch of already-queued video downloads, resolving each one's
|
||||||
|
/// transcode URL from the repository using the `quality_preset` stored on the
|
||||||
|
/// row. Then let the pump start them subject to the concurrency limit.
|
||||||
|
///
|
||||||
|
/// This is the bulk video path (series/season): `download_series`/
|
||||||
|
/// `download_season` insert the rows, then this resolves URLs and enqueues them
|
||||||
|
/// so they actually start. Resolving server-side avoids round-tripping every
|
||||||
|
/// episode URL through the frontend.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn enqueue_video_downloads(
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
|
repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
handle: String,
|
||||||
|
download_ids: Vec<i64>,
|
||||||
|
target_dir: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
|
let repo = repository.0.get(&handle).ok_or("Repository not found")?;
|
||||||
|
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
for download_id in download_ids {
|
||||||
|
// Read the item + quality preset for this queued download.
|
||||||
|
let info_query = Query::with_params(
|
||||||
|
"SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(download_id)],
|
||||||
|
);
|
||||||
|
let (item_id, quality): (String, String) = match db_service
|
||||||
|
.query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(row) => row,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the transcode URL (pure URL builder, no server round-trip).
|
||||||
|
let stream_url = repo.as_ref().get_video_download_url(&item_id, &quality, None);
|
||||||
|
|
||||||
|
let update_query = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir.clone()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(update_query).await {
|
||||||
|
warn!("[enqueue_video] Failed to persist URL for download {}: {}", download_id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pump once: starts up to max_concurrent, the rest drain as slots free.
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start as many pending downloads as there are free concurrency slots.
|
||||||
|
///
|
||||||
|
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
||||||
|
/// (FIFO within a priority), registers each, flips it to `downloading`, and
|
||||||
|
/// spawns a worker. Each spawned worker calls this again on completion/failure,
|
||||||
|
/// so the queue drains itself without any frontend involvement.
|
||||||
|
pub(crate) async fn pump_download_queue(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
db_service: Arc<crate::storage::db_service::RusqliteService>,
|
||||||
|
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||||
|
) {
|
||||||
|
use crate::download::events::DownloadEvent;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
let max_concurrent = {
|
||||||
|
let manager = app.state::<DownloadManagerWrapper>();
|
||||||
|
let manager = match manager.0.lock() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[pump] Failed to lock download manager: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
manager.max_concurrent()
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// How many slots are free right now?
|
||||||
|
let free_slots = {
|
||||||
|
let active = match active_downloads.lock() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[pump] Failed to lock active downloads: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
max_concurrent.saturating_sub(active.len())
|
||||||
|
};
|
||||||
|
if free_slots == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next pending, startable download (has a stream URL). Exclude
|
||||||
|
// anything already registered as active to avoid double-starting.
|
||||||
|
let next_query = Query::with_params(
|
||||||
|
"SELECT id, item_id, file_path, stream_url, target_dir
|
||||||
|
FROM downloads
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND stream_url IS NOT NULL
|
||||||
|
AND target_dir IS NOT NULL
|
||||||
|
ORDER BY priority DESC, queued_at ASC",
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidates: Vec<(i64, String, String, String, String)> = match db_service
|
||||||
|
.query_many(next_query, |row| {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[pump] Failed to query pending downloads: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick the first candidate not already active.
|
||||||
|
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
|
||||||
|
active_downloads
|
||||||
|
.lock()
|
||||||
|
.map(|active| !active.contains(id))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
|
||||||
|
Some(n) => n,
|
||||||
|
None => return, // Nothing pending to start
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the slot. If registration fails (race: another pump filled
|
||||||
|
// the last slot), stop — we'll be re-pumped when a slot frees.
|
||||||
|
{
|
||||||
|
let manager = app.state::<DownloadManagerWrapper>();
|
||||||
|
let manager = match manager.0.lock() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[pump] Failed to lock download manager: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !manager.register_download(download_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"[pump] Download {} started. Active downloads: {}/{}",
|
||||||
|
download_id,
|
||||||
|
manager.active_count(),
|
||||||
|
manager.max_concurrent()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as downloading and stamp started_at.
|
||||||
|
let update_query = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
vec![QueryParam::Int64(download_id)],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(update_query).await {
|
||||||
|
error!("[pump] Failed to mark download {} downloading: {}", download_id, e);
|
||||||
|
if let Ok(mut a) = active_downloads.lock() {
|
||||||
|
a.remove(&download_id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit started event so the UI flips the row.
|
||||||
|
let _ = app.emit(
|
||||||
|
"download-event",
|
||||||
|
DownloadEvent::Started {
|
||||||
|
download_id,
|
||||||
|
item_id: item_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let target_path = PathBuf::from(&target_dir).join(&file_path);
|
||||||
|
spawn_download_worker(
|
||||||
|
app.clone(),
|
||||||
|
download_id,
|
||||||
|
item_id,
|
||||||
|
stream_url,
|
||||||
|
target_path,
|
||||||
|
active_downloads.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the background worker for one download. On completion or failure it
|
||||||
|
/// unregisters the slot, emits the terminal event, and pumps the queue so the
|
||||||
|
/// next pending download starts automatically.
|
||||||
|
fn spawn_download_worker(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
download_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
stream_url: String,
|
||||||
|
target_path: std::path::PathBuf,
|
||||||
|
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
|
||||||
|
) {
|
||||||
|
use crate::download::{DownloadTask, DownloadWorker};
|
||||||
|
use crate::download::events::DownloadEvent;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
let task = DownloadTask {
|
||||||
|
url: stream_url,
|
||||||
|
target_path: target_path.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
debug!("Download task started for download_id: {}", download_id);
|
debug!("Download task started for download_id: {}", download_id);
|
||||||
let worker = DownloadWorker::new();
|
let worker = DownloadWorker::new();
|
||||||
|
|
||||||
// Progress callback that emits events to the frontend
|
// Progress callback that emits events to the frontend
|
||||||
let progress_app = app_clone.clone();
|
let progress_app = app.clone();
|
||||||
let progress_item_id = item_id_clone.clone();
|
let progress_item_id = item_id.clone();
|
||||||
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
|
||||||
let progress = total_bytes
|
let progress = total_bytes
|
||||||
.filter(|&t| t > 0)
|
.filter(|&t| t > 0)
|
||||||
@@ -929,25 +1260,60 @@ pub async fn start_download(
|
|||||||
let _ = progress_app.emit("download-event", event);
|
let _ = progress_app.emit("download-event", event);
|
||||||
};
|
};
|
||||||
|
|
||||||
match worker.download(&task, on_progress).await {
|
let result = worker.download(&task, on_progress).await;
|
||||||
Ok(result) => {
|
|
||||||
info!("Download completed successfully: {} bytes", result.bytes_downloaded);
|
|
||||||
|
|
||||||
// Unregister from download manager
|
// Free the slot before pumping so the next download can take it.
|
||||||
if let Ok(mut active) = active_downloads.lock() {
|
if let Ok(mut active) = active_downloads.lock() {
|
||||||
active.remove(&download_id);
|
active.remove(&download_id);
|
||||||
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
debug!(" Unregistered download {}. Active downloads: {}", download_id, active.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit completed event - the frontend will handle state updates
|
// The pump runs downloads in the background, so the terminal status MUST
|
||||||
|
// be persisted to the DB here — the frontend event handler only writes it
|
||||||
|
// when that download happens to be loaded in its store, which is not the
|
||||||
|
// case for auto-pumped rows (or any completion while the downloads page is
|
||||||
|
// closed). `check_for_local_download` filters on status = 'completed', so a
|
||||||
|
// missed write leaves finished files unrecognized: albums never show as
|
||||||
|
// downloaded and playback never switches from the (expiring) stream to the
|
||||||
|
// local file, cutting tracks off mid-play.
|
||||||
|
let db_service = {
|
||||||
|
let db = app.state::<DatabaseWrapper>();
|
||||||
|
let database = match db.0.lock() {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
error!("[pump] Failed to lock database after download {}: {}", download_id, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(res) => {
|
||||||
|
info!("Download completed successfully: {} bytes", res.bytes_downloaded);
|
||||||
|
let file_path = target_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let update = Query::with_params(
|
||||||
|
"UPDATE downloads SET status = 'completed', progress = 1.0, \
|
||||||
|
bytes_downloaded = ?, file_size = ?, file_path = ?, \
|
||||||
|
completed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||||
|
QueryParam::Int64(res.bytes_downloaded as i64),
|
||||||
|
QueryParam::String(file_path.clone()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(e) = db_service.execute(update).await {
|
||||||
|
error!("[pump] Failed to persist completed status for download {}: {}", download_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
let completed_event = DownloadEvent::Completed {
|
let completed_event = DownloadEvent::Completed {
|
||||||
download_id,
|
download_id,
|
||||||
item_id: item_id_clone,
|
item_id,
|
||||||
file_path: target_path.to_string_lossy().to_string(),
|
file_path,
|
||||||
};
|
};
|
||||||
debug!("Emitting completed event: {:?}", completed_event);
|
match app.emit("download-event", completed_event) {
|
||||||
debug!(" Serialized: {}", serde_json::to_string(&completed_event).unwrap_or_default());
|
|
||||||
match app_clone.emit("download-event", completed_event) {
|
|
||||||
Ok(_) => debug!(" Completed event emitted successfully"),
|
Ok(_) => debug!(" Completed event emitted successfully"),
|
||||||
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
Err(e) => error!(" Completed event emit failed: {:?}", e),
|
||||||
}
|
}
|
||||||
@@ -955,32 +1321,37 @@ pub async fn start_download(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Download failed: {:?}", e);
|
error!("Download failed: {:?}", e);
|
||||||
|
|
||||||
// Unregister from download manager
|
let update = Query::with_params(
|
||||||
if let Ok(mut active) = active_downloads.lock() {
|
"UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
|
||||||
active.remove(&download_id);
|
vec![
|
||||||
debug!(" Unregistered failed download {}. Active downloads: {}", download_id, active.len());
|
QueryParam::String(e.to_string()),
|
||||||
|
QueryParam::Int64(download_id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if let Err(db_err) = db_service.execute(update).await {
|
||||||
|
error!("[pump] Failed to persist failed status for download {}: {}", download_id, db_err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit failed event - the frontend will handle state updates
|
|
||||||
let failed_event = DownloadEvent::Failed {
|
let failed_event = DownloadEvent::Failed {
|
||||||
download_id,
|
download_id,
|
||||||
item_id: item_id_clone.clone(),
|
item_id,
|
||||||
error: e.to_string(),
|
error: e.to_string(),
|
||||||
};
|
};
|
||||||
debug!("Emitting failed event: {:?}", failed_event);
|
match app.emit("download-event", failed_event) {
|
||||||
match app_clone.emit("download-event", failed_event) {
|
|
||||||
Ok(_) => debug!(" Failed event emitted successfully"),
|
Ok(_) => debug!(" Failed event emitted successfully"),
|
||||||
Err(e) => error!(" Failed event emit failed: {:?}", e),
|
Err(e) => error!(" Failed event emit failed: {:?}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
// A slot just freed — start the next pending download (if any).
|
||||||
|
pump_download_queue(app.clone(), db_service, active_downloads).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a completed download
|
/// Delete a completed download
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
pub async fn delete_download(db: State<'_, DatabaseWrapper>, download_id: i64) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1048,7 +1419,7 @@ fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Storage statistics for downloads
|
/// Storage statistics for downloads
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct StorageStats {
|
pub struct StorageStats {
|
||||||
pub total_bytes: i64,
|
pub total_bytes: i64,
|
||||||
pub total_items: i64,
|
pub total_items: i64,
|
||||||
@@ -1056,7 +1427,7 @@ pub struct StorageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Storage info for a single album
|
/// Storage info for a single album
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct AlbumStorageInfo {
|
pub struct AlbumStorageInfo {
|
||||||
pub album_id: String,
|
pub album_id: String,
|
||||||
pub album_name: String,
|
pub album_name: String,
|
||||||
@@ -1067,6 +1438,7 @@ pub struct AlbumStorageInfo {
|
|||||||
|
|
||||||
/// Get storage statistics for downloads
|
/// Get storage statistics for downloads
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_download_storage_stats(
|
pub async fn get_download_storage_stats(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -1127,6 +1499,7 @@ pub async fn get_download_storage_stats(
|
|||||||
|
|
||||||
/// Delete all downloads for a user
|
/// Delete all downloads for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
|
pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: String) -> Result<i64, String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1166,6 +1539,7 @@ pub async fn delete_all_downloads(db: State<'_, DatabaseWrapper>, user_id: Strin
|
|||||||
|
|
||||||
/// Clear all stale pending/failed/paused downloads
|
/// Clear all stale pending/failed/paused downloads
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn clear_stale_downloads(
|
pub async fn clear_stale_downloads(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -1208,6 +1582,7 @@ pub async fn clear_stale_downloads(
|
|||||||
|
|
||||||
/// Delete all downloads for a specific album
|
/// Delete all downloads for a specific album
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn delete_album_downloads(
|
pub async fn delete_album_downloads(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
album_id: String,
|
album_id: String,
|
||||||
@@ -1252,7 +1627,7 @@ pub async fn delete_album_downloads(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Download manager statistics
|
/// Download manager statistics
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct DownloadManagerStats {
|
pub struct DownloadManagerStats {
|
||||||
pub max_concurrent: usize,
|
pub max_concurrent: usize,
|
||||||
pub active_count: usize,
|
pub active_count: usize,
|
||||||
@@ -1261,6 +1636,7 @@ pub struct DownloadManagerStats {
|
|||||||
|
|
||||||
/// Get download manager statistics
|
/// Get download manager statistics
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_download_manager_stats(
|
pub async fn get_download_manager_stats(
|
||||||
download_manager: State<'_, DownloadManagerWrapper>,
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
) -> Result<DownloadManagerStats, String> {
|
) -> Result<DownloadManagerStats, String> {
|
||||||
@@ -1278,6 +1654,7 @@ pub async fn get_download_manager_stats(
|
|||||||
|
|
||||||
/// Set the maximum concurrent downloads
|
/// Set the maximum concurrent downloads
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn set_max_concurrent_downloads(
|
pub async fn set_max_concurrent_downloads(
|
||||||
download_manager: State<'_, DownloadManagerWrapper>,
|
download_manager: State<'_, DownloadManagerWrapper>,
|
||||||
max: usize,
|
max: usize,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
|||||||
|
|
||||||
/// Pin an item's metadata (protects from cache clear)
|
/// Pin an item's metadata (protects from cache clear)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -25,6 +26,7 @@ pub async fn pin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result
|
|||||||
|
|
||||||
/// Unpin an item's metadata
|
/// Unpin an item's metadata
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<(), String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -42,6 +44,7 @@ pub async fn unpin_item(db: State<'_, DatabaseWrapper>, item_id: String) -> Resu
|
|||||||
|
|
||||||
/// Check if an item is pinned
|
/// Check if an item is pinned
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
pub async fn is_item_pinned(db: State<'_, DatabaseWrapper>, item_id: String) -> Result<bool, String> {
|
||||||
let db_service = {
|
let db_service = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::commands::{DatabaseWrapper, SmartCacheWrapper};
|
|||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
/// SmartCache statistics
|
/// SmartCache statistics
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct SmartCacheStats {
|
pub struct SmartCacheStats {
|
||||||
pub total_size: u64,
|
pub total_size: u64,
|
||||||
pub storage_limit: u64,
|
pub storage_limit: u64,
|
||||||
@@ -19,6 +19,7 @@ pub struct SmartCacheStats {
|
|||||||
|
|
||||||
/// Get SmartCache statistics
|
/// Get SmartCache statistics
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_smart_cache_stats(
|
pub async fn get_smart_cache_stats(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
@@ -67,6 +68,7 @@ pub async fn get_smart_cache_stats(
|
|||||||
|
|
||||||
/// Update SmartCache configuration
|
/// Update SmartCache configuration
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn update_smart_cache_config(
|
pub async fn update_smart_cache_config(
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
config: crate::download::cache::CacheConfig,
|
config: crate::download::cache::CacheConfig,
|
||||||
@@ -79,6 +81,7 @@ pub async fn update_smart_cache_config(
|
|||||||
|
|
||||||
/// Get SmartCache configuration
|
/// Get SmartCache configuration
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_smart_cache_config(
|
pub async fn get_smart_cache_config(
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
) -> Result<crate::download::cache::CacheConfig, String> {
|
) -> Result<crate::download::cache::CacheConfig, String> {
|
||||||
@@ -89,7 +92,7 @@ pub async fn get_smart_cache_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Album recommendation info
|
/// Album recommendation info
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
pub struct AlbumRecommendation {
|
pub struct AlbumRecommendation {
|
||||||
pub album_id: String,
|
pub album_id: String,
|
||||||
pub album_name: String,
|
pub album_name: String,
|
||||||
@@ -99,7 +102,7 @@ pub struct AlbumRecommendation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Album affinity status info
|
/// Album affinity status info
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AlbumAffinityStatus {
|
pub struct AlbumAffinityStatus {
|
||||||
pub album_id: String,
|
pub album_id: String,
|
||||||
@@ -110,6 +113,7 @@ pub struct AlbumAffinityStatus {
|
|||||||
|
|
||||||
/// Get album recommendations based on play history
|
/// Get album recommendations based on play history
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn get_album_recommendations(
|
pub async fn get_album_recommendations(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
@@ -189,6 +193,7 @@ pub async fn get_album_recommendations(
|
|||||||
/// Get album affinity status for all tracked albums
|
/// Get album affinity status for all tracked albums
|
||||||
/// This shows the SmartCache's internal play history and threshold status
|
/// This shows the SmartCache's internal play history and threshold status
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn get_album_affinity_status(
|
pub fn get_album_affinity_status(
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
) -> Result<Vec<AlbumAffinityStatus>, String> {
|
) -> Result<Vec<AlbumAffinityStatus>, String> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// TRACES: UR-002, UR-003, UR-004, UR-005, UR-009, UR-011, UR-012, UR-017, UR-019, UR-025 |
|
// 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
|
// DR-015, DR-017, DR-021, DR-028
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod catalog;
|
||||||
pub mod connectivity;
|
pub mod connectivity;
|
||||||
pub mod conversions;
|
pub mod conversions;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
@@ -17,6 +18,7 @@ pub mod storage;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
|
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
|
pub use catalog::*;
|
||||||
pub use connectivity::*;
|
pub use connectivity::*;
|
||||||
pub use conversions::*;
|
pub use conversions::*;
|
||||||
pub use device::*;
|
pub use device::*;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tauri::State;
|
|||||||
use super::DatabaseWrapper;
|
use super::DatabaseWrapper;
|
||||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(specta::Type, Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct OfflineItem {
|
pub struct OfflineItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -22,6 +22,7 @@ pub struct OfflineItem {
|
|||||||
|
|
||||||
/// Check if an item is available offline
|
/// Check if an item is available offline
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_is_available(
|
pub async fn offline_is_available(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -46,6 +47,7 @@ pub async fn offline_is_available(
|
|||||||
|
|
||||||
/// Get all offline items for a user
|
/// Get all offline items for a user
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_get_items(
|
pub async fn offline_get_items(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
@@ -84,6 +86,7 @@ pub async fn offline_get_items(
|
|||||||
|
|
||||||
/// Search offline items
|
/// Search offline items
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn offline_search(
|
pub async fn offline_search(
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct PlaybackModeManagerWrapper(pub Arc<PlaybackModeManager>);
|
|||||||
|
|
||||||
/// Get the current playback mode
|
/// Get the current playback mode
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_get_current(
|
pub fn playback_mode_get_current(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<PlaybackMode, String> {
|
) -> Result<PlaybackMode, String> {
|
||||||
@@ -16,6 +17,7 @@ pub fn playback_mode_get_current(
|
|||||||
|
|
||||||
/// Set the playback mode (internal/testing use)
|
/// Set the playback mode (internal/testing use)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_set(
|
pub fn playback_mode_set(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
mode: PlaybackMode,
|
mode: PlaybackMode,
|
||||||
@@ -26,6 +28,7 @@ pub fn playback_mode_set(
|
|||||||
|
|
||||||
/// Check if currently transferring between playback modes
|
/// Check if currently transferring between playback modes
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub fn playback_mode_is_transferring(
|
pub fn playback_mode_is_transferring(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
@@ -34,15 +37,34 @@ pub fn playback_mode_is_transferring(
|
|||||||
|
|
||||||
/// Transfer playback from local device to a remote Jellyfin session
|
/// Transfer playback from local device to a remote Jellyfin session
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_remote(
|
pub async fn playback_mode_transfer_to_remote(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
position: Option<f64>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[PlaybackModeCommands] Transferring to remote session: {}",
|
"[PlaybackModeCommands] Transferring to remote session: {} (position override: {:?})",
|
||||||
session_id
|
session_id,
|
||||||
|
position
|
||||||
);
|
);
|
||||||
manager.0.transfer_to_remote(session_id).await
|
manager.0.transfer_to_remote(session_id, position).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the transferring flag on the playback mode manager.
|
||||||
|
///
|
||||||
|
/// Used by the frontend remote->local flow to mark the whole two-step sequence
|
||||||
|
/// as a transfer, so `player_play_tracks` starts LOCAL playback instead of
|
||||||
|
/// casting back to the remote session it's leaving. Always pair `true` with a
|
||||||
|
/// later `false` (including on error) so the flag can't stick.
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn playback_mode_set_transferring(
|
||||||
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
|
transferring: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
manager.0.set_transferring(transferring);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfer playback from remote session back to local device
|
/// Transfer playback from remote session back to local device
|
||||||
@@ -51,6 +73,7 @@ pub async fn playback_mode_transfer_to_remote(
|
|||||||
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
/// - current_item_id: The Jellyfin item ID currently playing on remote
|
||||||
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
/// - position_ticks: Current playback position in ticks (10,000 ticks = 1ms)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_transfer_to_local(
|
pub async fn playback_mode_transfer_to_local(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
current_item_id: String,
|
current_item_id: String,
|
||||||
@@ -69,6 +92,7 @@ pub async fn playback_mode_transfer_to_local(
|
|||||||
|
|
||||||
/// Get remote session status (for polling position/duration)
|
/// Get remote session status (for polling position/duration)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mode_get_remote_status(
|
pub async fn playback_mode_get_remote_status(
|
||||||
manager: State<'_, PlaybackModeManagerWrapper>,
|
manager: State<'_, PlaybackModeManagerWrapper>,
|
||||||
player: State<'_, crate::commands::PlayerStateWrapper>,
|
player: State<'_, crate::commands::PlayerStateWrapper>,
|
||||||
@@ -119,7 +143,7 @@ pub async fn playback_mode_get_remote_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remote session status for UI updates
|
/// Remote session status for UI updates
|
||||||
#[derive(serde::Serialize)]
|
#[derive(specta::Type, serde::Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RemoteSessionStatus {
|
pub struct RemoteSessionStatus {
|
||||||
pub position: f64,
|
pub position: f64,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct PlaybackReporterWrapper(pub Arc<TokioMutex<Option<PlaybackReporter>>>
|
|||||||
|
|
||||||
/// Initialize playback reporter (called after login)
|
/// Initialize playback reporter (called after login)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_init(
|
pub async fn playback_reporter_init(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -66,6 +67,7 @@ pub async fn playback_reporter_init(
|
|||||||
|
|
||||||
/// Destroy playback reporter (called on logout)
|
/// Destroy playback reporter (called on logout)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_reporter_destroy(
|
pub async fn playback_reporter_destroy(
|
||||||
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
reporter_wrapper: State<'_, PlaybackReporterWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -76,6 +78,7 @@ pub async fn playback_reporter_destroy(
|
|||||||
|
|
||||||
/// Report playback start
|
/// Report playback start
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_start(
|
pub async fn playback_report_start(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -110,6 +113,7 @@ pub async fn playback_report_start(
|
|||||||
|
|
||||||
/// Report playback progress
|
/// Report playback progress
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_progress(
|
pub async fn playback_report_progress(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -138,6 +142,7 @@ pub async fn playback_report_progress(
|
|||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_report_stopped(
|
pub async fn playback_report_stopped(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
@@ -164,6 +169,7 @@ pub async fn playback_report_stopped(
|
|||||||
|
|
||||||
/// Mark item as played
|
/// Mark item as played
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn playback_mark_played(
|
pub async fn playback_mark_played(
|
||||||
reporter: State<'_, PlaybackReporterWrapper>,
|
reporter: State<'_, PlaybackReporterWrapper>,
|
||||||
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
connectivity: State<'_, ConnectivityMonitorWrapper>,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
|
|||||||
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
|
||||||
|
|
||||||
/// Response for player state queries
|
/// Response for player state queries
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayerStatus {
|
pub struct PlayerStatus {
|
||||||
pub state: PlayerState,
|
pub state: PlayerState,
|
||||||
@@ -82,7 +82,7 @@ pub struct PlayerStatus {
|
|||||||
|
|
||||||
/// Lightweight media item for merged playback state
|
/// Lightweight media item for merged playback state
|
||||||
/// Converts from both local MediaItem and remote NowPlayingItem
|
/// Converts from both local MediaItem and remote NowPlayingItem
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(specta::Type, Debug, Serialize, Clone)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MergedMediaItem {
|
pub struct MergedMediaItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -134,7 +134,7 @@ impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Response for queue queries
|
/// Response for queue queries
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct QueueStatus {
|
pub struct QueueStatus {
|
||||||
pub items: Vec<MediaItem>,
|
pub items: Vec<MediaItem>,
|
||||||
@@ -146,7 +146,7 @@ pub struct QueueStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Backend type for video playback
|
/// Backend type for video playback
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum VideoBackend {
|
pub enum VideoBackend {
|
||||||
/// Native backend (ExoPlayer on Android, libmpv on Linux)
|
/// Native backend (ExoPlayer on Android, libmpv on Linux)
|
||||||
@@ -159,7 +159,7 @@ pub enum VideoBackend {
|
|||||||
///
|
///
|
||||||
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
||||||
/// to avoid Tauri Android serialization issues with complex objects.
|
/// to avoid Tauri Android serialization issues with complex objects.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayItemRequest {
|
pub struct PlayItemRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -172,7 +172,7 @@ pub struct PlayItemRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum PlayQueueContext {
|
pub enum PlayQueueContext {
|
||||||
/// Playing from a specific album
|
/// Playing from a specific album
|
||||||
@@ -194,7 +194,7 @@ pub enum PlayQueueContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to play a queue of items
|
/// Request to play a queue of items
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayQueueRequest {
|
pub struct PlayQueueRequest {
|
||||||
pub items: Vec<PlayItemRequest>,
|
pub items: Vec<PlayItemRequest>,
|
||||||
@@ -207,7 +207,7 @@ pub struct PlayQueueRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to play a track from an album (backend fetches all tracks)
|
/// Request to play a track from an album (backend fetches all tracks)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayAlbumTrackRequest {
|
pub struct PlayAlbumTrackRequest {
|
||||||
pub album_id: String,
|
pub album_id: String,
|
||||||
@@ -217,17 +217,21 @@ pub struct PlayAlbumTrackRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to play tracks by ID (backend fetches metadata)
|
/// Request to play tracks by ID (backend fetches metadata)
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlayTracksRequest {
|
pub struct PlayTracksRequest {
|
||||||
pub track_ids: Vec<String>,
|
pub track_ids: Vec<String>,
|
||||||
pub start_index: usize,
|
pub start_index: usize,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub context: PlayTracksContext,
|
pub context: PlayTracksContext,
|
||||||
|
/// Position (seconds) to resume the starting track from. Used when taking
|
||||||
|
/// over playback from a remote session so we don't restart from 0.
|
||||||
|
#[serde(default)]
|
||||||
|
pub start_position: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Context information for track playback
|
/// Context information for track playback
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "lowercase")]
|
#[serde(tag = "type", rename_all = "lowercase")]
|
||||||
pub enum PlayTracksContext {
|
pub enum PlayTracksContext {
|
||||||
Playlist {
|
Playlist {
|
||||||
@@ -249,7 +253,7 @@ pub enum PlayTracksContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Response for video seek operations
|
/// Response for video seek operations
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(tag = "strategy", rename_all = "camelCase")]
|
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||||
pub enum VideoSeekResponse {
|
pub enum VideoSeekResponse {
|
||||||
/// Use native seeking (HLS or direct stream)
|
/// Use native seeking (HLS or direct stream)
|
||||||
@@ -267,7 +271,7 @@ pub enum VideoSeekResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Response for audio track switching operations
|
/// Response for audio track switching operations
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(tag = "strategy", rename_all = "camelCase")]
|
#[serde(tag = "strategy", rename_all = "camelCase")]
|
||||||
pub enum AudioTrackSwitchResponse {
|
pub enum AudioTrackSwitchResponse {
|
||||||
/// Native backend handled it (Android ExoPlayer)
|
/// Native backend handled it (Android ExoPlayer)
|
||||||
@@ -374,6 +378,65 @@ pub(super) async fn check_for_local_download(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-point queued streaming items at completed local downloads.
|
||||||
|
///
|
||||||
|
/// Sources are resolved once when the queue is built, so downloads that finish
|
||||||
|
/// while it plays (preloaded upcoming tracks) — or that existed before the
|
||||||
|
/// connection dropped — would otherwise keep streaming. Called before advancing
|
||||||
|
/// so the next track always prefers the on-disk copy.
|
||||||
|
///
|
||||||
|
/// Returns the number of items switched to a local source.
|
||||||
|
pub(super) async fn refresh_queue_local_sources(
|
||||||
|
controller: &PlayerController,
|
||||||
|
db: &DatabaseWrapper,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
// Collect remote item IDs first; the queue lock must not be held across awaits.
|
||||||
|
let remote_ids: Vec<String> = {
|
||||||
|
let queue = controller.queue();
|
||||||
|
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
queue_lock
|
||||||
|
.items()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match &item.source {
|
||||||
|
MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if remote_ids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut local_paths: Vec<(String, String)> = Vec::new();
|
||||||
|
for id in remote_ids {
|
||||||
|
if let Some(path) = check_for_local_download(db, &id).await? {
|
||||||
|
local_paths.push((id, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if local_paths.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue = controller.queue();
|
||||||
|
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
|
||||||
|
let mut switched = 0;
|
||||||
|
for item in queue_lock.items_mut() {
|
||||||
|
if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source {
|
||||||
|
if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
|
||||||
|
info!("[Player] Switching queued track {} to local download: {}", id, path);
|
||||||
|
item.source = MediaSource::Local {
|
||||||
|
file_path: PathBuf::from(path),
|
||||||
|
jellyfin_item_id: Some(id.clone()),
|
||||||
|
};
|
||||||
|
switched += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(switched)
|
||||||
|
}
|
||||||
|
|
||||||
/// Play a single media item (audio or video)
|
/// Play a single media item (audio or video)
|
||||||
///
|
///
|
||||||
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
|
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
|
||||||
@@ -384,6 +447,7 @@ pub(super) async fn check_for_local_download(
|
|||||||
/// @req: UR-005 - Control media playback (play operation)
|
/// @req: UR-005 - Control media playback (play operation)
|
||||||
/// @req: DR-009 - Audio player UI
|
/// @req: DR-009 - Audio player UI
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_play_item(
|
pub async fn player_play_item(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
@@ -410,9 +474,22 @@ pub async fn player_play_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
|
||||||
|
// get_player_status -> use_html5_element). The MPV backend has no embedded
|
||||||
|
// window, so loading the stream into it would only start a redundant decode
|
||||||
|
// (and the frontend would immediately stop it). Only load into the native
|
||||||
|
// backend on platforms that actually render video through it (e.g. Android).
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
controller
|
controller
|
||||||
.play_item(media_item)
|
.play_item(media_item)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Keep the queue in sync for UI/remote-transfer without starting MPV.
|
||||||
|
controller
|
||||||
|
.set_current_item(media_item)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Emit queue changed event
|
// Emit queue changed event
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
@@ -435,6 +512,7 @@ pub async fn player_play_item(
|
|||||||
/// @req: UR-015 - View and manage current audio queue
|
/// @req: UR-015 - View and manage current audio queue
|
||||||
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
/// @req: DR-005 - Queue manager with shuffle, repeat, history
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_play_queue(
|
pub async fn player_play_queue(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
@@ -511,6 +589,7 @@ pub async fn player_play_queue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_play(
|
pub async fn player_play(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -538,6 +617,7 @@ pub async fn player_play(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_pause(
|
pub async fn player_pause(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -565,6 +645,7 @@ pub async fn player_pause(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_toggle(
|
pub async fn player_toggle(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -592,6 +673,7 @@ pub async fn player_toggle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_stop(
|
pub async fn player_stop(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
@@ -614,6 +696,11 @@ pub async fn player_stop(
|
|||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.stop().map_err(|e| e.to_string())?;
|
controller.stop().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// A genuine local stop returns the manager to Idle so it no longer
|
||||||
|
// reports Local (or a stale Remote) — otherwise a later play/pause would
|
||||||
|
// route to the wrong device.
|
||||||
|
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Idle);
|
||||||
|
|
||||||
// Handle session state based on type (local playback only)
|
// Handle session state based on type (local playback only)
|
||||||
{
|
{
|
||||||
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
|
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -651,10 +738,12 @@ pub async fn player_stop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_next(
|
pub async fn player_next(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
debug!("[player_next] Command called from frontend");
|
debug!("[player_next] Command called from frontend");
|
||||||
|
|
||||||
@@ -673,6 +762,10 @@ pub async fn player_next(
|
|||||||
} else {
|
} else {
|
||||||
// Local playback
|
// Local playback
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
// Prefer downloads that completed since the queue was built
|
||||||
|
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
|
||||||
|
warn!("[player_next] Failed to refresh local sources: {}", e);
|
||||||
|
}
|
||||||
controller.next().map_err(|e| e.to_string())?;
|
controller.next().map_err(|e| e.to_string())?;
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
@@ -703,10 +796,12 @@ pub async fn player_next(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_previous(
|
pub async fn player_previous(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
// Check if we're in remote mode
|
// Check if we're in remote mode
|
||||||
let mode = playback_mode.0.get_mode();
|
let mode = playback_mode.0.get_mode();
|
||||||
@@ -723,6 +818,10 @@ pub async fn player_previous(
|
|||||||
} else {
|
} else {
|
||||||
// Local playback
|
// Local playback
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
// Prefer downloads that completed since the queue was built
|
||||||
|
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
|
||||||
|
warn!("[player_previous] Failed to refresh local sources: {}", e);
|
||||||
|
}
|
||||||
controller.previous().map_err(|e| e.to_string())?;
|
controller.previous().map_err(|e| e.to_string())?;
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
|
|
||||||
@@ -753,6 +852,7 @@ pub async fn player_previous(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_seek(
|
pub async fn player_seek(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -792,6 +892,7 @@ pub async fn player_seek(
|
|||||||
/// For native (non-HTML5) backends, this command handles the entire stream reload
|
/// For native (non-HTML5) backends, this command handles the entire stream reload
|
||||||
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_seek_video(
|
pub async fn player_seek_video(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
@@ -929,6 +1030,7 @@ pub async fn player_seek_video(
|
|||||||
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
|
||||||
/// Note: Frontend should handle saving series preferences after this command succeeds
|
/// Note: Frontend should handle saving series preferences after this command succeeds
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_switch_audio_track(
|
pub async fn player_switch_audio_track(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
@@ -984,6 +1086,7 @@ pub async fn player_switch_audio_track(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_set_audio_track(
|
pub async fn player_set_audio_track(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
stream_index: i32,
|
stream_index: i32,
|
||||||
@@ -994,6 +1097,7 @@ pub async fn player_set_audio_track(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_set_subtitle_track(
|
pub async fn player_set_subtitle_track(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
stream_index: Option<i32>,
|
stream_index: Option<i32>,
|
||||||
@@ -1004,6 +1108,7 @@ pub async fn player_set_subtitle_track(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_set_volume(
|
pub async fn player_set_volume(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -1034,6 +1139,7 @@ pub async fn player_set_volume(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_toggle_mute(
|
pub async fn player_toggle_mute(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -1062,6 +1168,7 @@ pub async fn player_toggle_mute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_toggle_shuffle(
|
pub async fn player_toggle_shuffle(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
) -> Result<QueueStatus, String> {
|
) -> Result<QueueStatus, String> {
|
||||||
@@ -1072,6 +1179,7 @@ pub async fn player_toggle_shuffle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_cycle_repeat(player: State<'_, PlayerStateWrapper>) -> Result<QueueStatus, String> {
|
pub async fn player_cycle_repeat(player: State<'_, PlayerStateWrapper>) -> Result<QueueStatus, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.cycle_repeat();
|
controller.cycle_repeat();
|
||||||
@@ -1080,6 +1188,7 @@ pub async fn player_cycle_repeat(player: State<'_, PlayerStateWrapper>) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_get_status(
|
pub async fn player_get_status(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
@@ -1165,6 +1274,7 @@ pub async fn player_get_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_get_queue(player: State<'_, PlayerStateWrapper>) -> Result<QueueStatus, String> {
|
pub async fn player_get_queue(player: State<'_, PlayerStateWrapper>) -> Result<QueueStatus, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
Ok(get_queue_status(&controller))
|
Ok(get_queue_status(&controller))
|
||||||
@@ -1213,14 +1323,69 @@ pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a freshly-built queue on the active remote session.
|
||||||
|
///
|
||||||
|
/// Used by the "play tracks"/"play album track" commands when we're in remote
|
||||||
|
/// mode: instead of starting local MPV playback, we cast the selected tracks to
|
||||||
|
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
|
||||||
|
/// play_on_session call, but for a brand-new selection (so there's no resume
|
||||||
|
/// position - playback starts from the chosen track's beginning).
|
||||||
|
///
|
||||||
|
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
|
||||||
|
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
|
||||||
|
/// if the selected track itself has no Jellyfin ID.
|
||||||
|
async fn play_selection_on_remote(
|
||||||
|
controller: &PlayerController,
|
||||||
|
session_id: &str,
|
||||||
|
media_items: &[MediaItem],
|
||||||
|
start_index: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Collect Jellyfin IDs, tracking where the selected track lands after any
|
||||||
|
// local-only items are dropped.
|
||||||
|
let mut jellyfin_ids: Vec<String> = Vec::new();
|
||||||
|
let mut adjusted_index: Option<usize> = None;
|
||||||
|
for (i, item) in media_items.iter().enumerate() {
|
||||||
|
if let Some(id) = item.jellyfin_id() {
|
||||||
|
if i == start_index {
|
||||||
|
adjusted_index = Some(jellyfin_ids.len());
|
||||||
|
}
|
||||||
|
jellyfin_ids.push(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_index = adjusted_index
|
||||||
|
.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
|
||||||
|
|
||||||
|
if jellyfin_ids.is_empty() {
|
||||||
|
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = {
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fresh selection: start from the beginning of the chosen track.
|
||||||
|
client
|
||||||
|
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Play a track from an album - backend fetches all album tracks and builds queue
|
/// Play a track from an album - backend fetches all album tracks and builds queue
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_play_album_track(
|
pub async fn player_play_album_track(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: PlayAlbumTrackRequest,
|
request: PlayAlbumTrackRequest,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -1262,13 +1427,14 @@ pub async fn player_play_album_track(
|
|||||||
info!(" [{}] {} (ID: {})", idx, track.name, track.id);
|
info!(" [{}] {} (ID: {})", idx, track.name, track.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the index of the requested track
|
// Validate the requested track exists in the album (its position in the
|
||||||
|
// final queue is computed after building, since offline tracks are skipped).
|
||||||
info!("Looking for track_id: {}", request.track_id);
|
info!("Looking for track_id: {}", request.track_id);
|
||||||
let start_index = tracks.iter()
|
let album_index = tracks.iter()
|
||||||
.position(|t| t.id == request.track_id)
|
.position(|t| t.id == request.track_id)
|
||||||
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
|
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
|
||||||
|
|
||||||
info!("Track {} is at index {} in album", request.track_id, start_index);
|
info!("Track {} is at index {} in album", request.track_id, album_index);
|
||||||
|
|
||||||
// Convert tracks to MediaItems
|
// Convert tracks to MediaItems
|
||||||
let mut media_items = Vec::new();
|
let mut media_items = Vec::new();
|
||||||
@@ -1283,13 +1449,21 @@ pub async fn player_play_album_track(
|
|||||||
jellyfin_item_id: Some(jellyfin_id.clone()),
|
jellyfin_item_id: Some(jellyfin_id.clone()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get stream URL from repository (works online/offline)
|
// Non-downloaded track: needs a stream URL from the server. When the
|
||||||
let stream_url = repository.get_audio_stream_url(&track.id).await
|
// server is unreachable (offline), skip this track rather than failing
|
||||||
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
// the whole album — downloaded tracks must still be playable.
|
||||||
|
match repository.get_audio_stream_url(&track.id).await {
|
||||||
MediaSource::Remote {
|
Ok(stream_url) => MediaSource::Remote {
|
||||||
stream_url,
|
stream_url,
|
||||||
jellyfin_item_id: jellyfin_id.clone(),
|
jellyfin_item_id: jellyfin_id.clone(),
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
|
||||||
|
track.name, track.id, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1329,6 +1503,18 @@ pub async fn player_play_album_track(
|
|||||||
media_items.push(media_item);
|
media_items.push(media_item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if media_items.is_empty() {
|
||||||
|
return Err("No playable tracks available (offline and nothing downloaded)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks with no local download and no reachable server were skipped above,
|
||||||
|
// so positions shifted. Re-locate the requested track in the built queue.
|
||||||
|
// If the tapped track itself was skipped, fall back to the first item.
|
||||||
|
let start_index = media_items
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.id == request.track_id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index);
|
info!("Built queue with {} media items, starting at index {}", media_items.len(), start_index);
|
||||||
|
|
||||||
// Handle shuffle before setting queue
|
// Handle shuffle before setting queue
|
||||||
@@ -1343,11 +1529,27 @@ pub async fn player_play_album_track(
|
|||||||
session_mgr.start_audio_session(first_item.clone());
|
session_mgr.start_audio_session(first_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play the queue
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
|
// When controlling a remote session, cast the selection there instead of
|
||||||
|
// starting local MPV playback. We still load the queue locally (below) so
|
||||||
|
// the queue/context stay in sync for the UI and for transferring back.
|
||||||
|
let remote_session = match playback_mode.0.get_mode() {
|
||||||
|
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(session_id) = &remote_session {
|
||||||
|
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
|
||||||
|
controller.set_queue(media_items, start_index).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
// Local playback is now authoritative (see player_play_tracks); set it
|
||||||
|
// before starting so the mode-changed event precedes the state events.
|
||||||
|
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Local);
|
||||||
controller
|
controller
|
||||||
.play_queue(media_items, start_index)
|
.play_queue(media_items, start_index)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Set the queue context for remote transfer
|
// Set the queue context for remote transfer
|
||||||
{
|
{
|
||||||
@@ -1383,11 +1585,13 @@ pub async fn player_play_album_track(
|
|||||||
|
|
||||||
/// Play tracks by ID - backend fetches all metadata
|
/// Play tracks by ID - backend fetches all metadata
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_play_tracks(
|
pub async fn player_play_tracks(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
session: State<'_, MediaSessionManagerWrapper>,
|
session: State<'_, MediaSessionManagerWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
|
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
request: PlayTracksRequest,
|
request: PlayTracksRequest,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
@@ -1495,10 +1699,43 @@ pub async fn player_play_tracks(
|
|||||||
session_mgr.start_audio_session(first_item.clone());
|
session_mgr.start_audio_session(first_item.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play queue
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.play_queue(media_items, request.start_index)
|
|
||||||
|
// When controlling a remote session, cast the selection there instead of
|
||||||
|
// starting local MPV playback. Skip this while a transfer is in flight: the
|
||||||
|
// transfer-to-local path calls this command to load the queue locally and
|
||||||
|
// the mode is still Remote until the transfer completes - routing it back to
|
||||||
|
// the remote would undo the transfer.
|
||||||
|
let remote_session = match playback_mode.0.get_mode() {
|
||||||
|
crate::playback_mode::PlaybackMode::Remote { session_id }
|
||||||
|
if !playback_mode.0.is_transferring() =>
|
||||||
|
{
|
||||||
|
Some(session_id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(session_id) = &remote_session {
|
||||||
|
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
|
||||||
|
.await?;
|
||||||
|
controller
|
||||||
|
.set_queue(media_items, request.start_index)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
// Starting local playback makes Local the authoritative mode. Without
|
||||||
|
// this, a prior Remote mode lingers in the manager and later play/pause
|
||||||
|
// commands route back to the (stopped) remote session. Set it BEFORE
|
||||||
|
// starting playback so the PlaybackModeChanged event reaches the frontend
|
||||||
|
// ahead of the state_changed events it will emit — otherwise the frontend
|
||||||
|
// (still thinking it's remote) filters those state events out. Skip during
|
||||||
|
// a transfer: transfer_to_local drives the mode itself once complete.
|
||||||
|
if !playback_mode.0.is_transferring() {
|
||||||
|
playback_mode.0.set_mode(crate::playback_mode::PlaybackMode::Local);
|
||||||
|
}
|
||||||
|
controller
|
||||||
|
.play_queue_from(media_items, request.start_index, request.start_position)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Set queue context
|
// Set queue context
|
||||||
{
|
{
|
||||||
@@ -1521,7 +1758,7 @@ pub async fn player_play_tracks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Response for preload operation
|
/// Response for preload operation
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(specta::Type, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PreloadResult {
|
pub struct PreloadResult {
|
||||||
/// Number of tracks queued for preload
|
/// Number of tracks queued for preload
|
||||||
@@ -1535,16 +1772,28 @@ pub struct PreloadResult {
|
|||||||
/// Preload upcoming tracks from the queue
|
/// Preload upcoming tracks from the queue
|
||||||
/// This queues background downloads for the next N tracks that aren't already downloaded
|
/// This queues background downloads for the next N tracks that aren't already downloaded
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_preload_upcoming(
|
pub async fn player_preload_upcoming(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
|
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
_download_base_path: String,
|
_download_base_path: String,
|
||||||
) -> Result<PreloadResult, String> {
|
) -> Result<PreloadResult, String> {
|
||||||
let db_service = {
|
// The pump only starts rows that carry both a stream URL and a target dir,
|
||||||
|
// so resolve the same storage root the user-initiated download paths use
|
||||||
|
// (storage_get_path = the database's parent directory).
|
||||||
|
let (db_service, target_dir) = {
|
||||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
Arc::new(database.service())
|
let target_dir = database
|
||||||
|
.path()
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Database path has no parent directory".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
(Arc::new(database.service()), target_dir)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get cache settings
|
// Get cache settings
|
||||||
@@ -1582,10 +1831,12 @@ pub async fn player_preload_upcoming(
|
|||||||
|
|
||||||
// Process each upcoming item
|
// Process each upcoming item
|
||||||
for item in upcoming_items {
|
for item in upcoming_items {
|
||||||
// Only process items with Remote source (not already local)
|
// Only process items with Remote source (not already local). The
|
||||||
let jellyfin_id = match &item.source {
|
// source already carries the resolved stream URL — reuse it so the
|
||||||
MediaSource::Remote { jellyfin_item_id, .. } => {
|
// pump can start the download without any extra resolution step.
|
||||||
jellyfin_item_id.clone()
|
let (jellyfin_id, stream_url) = match &item.source {
|
||||||
|
MediaSource::Remote { jellyfin_item_id, stream_url } => {
|
||||||
|
(jellyfin_item_id.clone(), stream_url.clone())
|
||||||
}
|
}
|
||||||
MediaSource::Local { .. } => {
|
MediaSource::Local { .. } => {
|
||||||
already_downloaded += 1;
|
already_downloaded += 1;
|
||||||
@@ -1597,9 +1848,13 @@ pub async fn player_preload_upcoming(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if already downloaded
|
// Check if already downloaded or actively in flight. Stale pending rows
|
||||||
|
// without a stream URL are NOT skipped here — the upsert below heals
|
||||||
|
// them so the pump can finally start them.
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ? AND status IN ('completed', 'downloading', 'pending') LIMIT 1",
|
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
|
||||||
|
AND (status IN ('completed', 'downloading')
|
||||||
|
OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(jellyfin_id.clone()),
|
QueryParam::String(jellyfin_id.clone()),
|
||||||
QueryParam::String(user_id.clone()),
|
QueryParam::String(user_id.clone()),
|
||||||
@@ -1616,14 +1871,29 @@ pub async fn player_preload_upcoming(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue for download with low priority (preload priority = -100)
|
// Queue for download with low priority (preload priority = -100) so
|
||||||
let file_path = format!("{}/{}.mp3", sanitize_filename(&item.album.clone().unwrap_or_default()), sanitize_filename(&item.title));
|
// user-initiated downloads always win a pump slot first.
|
||||||
|
let album_dir = item
|
||||||
|
.album
|
||||||
|
.as_deref()
|
||||||
|
.filter(|a| !a.is_empty())
|
||||||
|
.unwrap_or("Unknown Album");
|
||||||
|
let file_path = format!(
|
||||||
|
"downloads/{}/{}.mp3",
|
||||||
|
sanitize_filename(album_dir),
|
||||||
|
sanitize_filename(&item.title)
|
||||||
|
);
|
||||||
|
|
||||||
// Insert download record with preload priority
|
// Insert with the stream URL + target dir the pump needs to start it.
|
||||||
|
// On conflict, heal pre-existing rows that were queued without a URL
|
||||||
|
// (they could never start) instead of leaving them stuck.
|
||||||
let insert_query = Query::with_params(
|
let insert_query = Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name)
|
"INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
|
||||||
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?)
|
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
|
||||||
ON CONFLICT(item_id, user_id) DO NOTHING",
|
ON CONFLICT(item_id, user_id) DO UPDATE SET
|
||||||
|
stream_url = excluded.stream_url,
|
||||||
|
target_dir = excluded.target_dir
|
||||||
|
WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(jellyfin_id),
|
QueryParam::String(jellyfin_id),
|
||||||
QueryParam::String(user_id.clone()),
|
QueryParam::String(user_id.clone()),
|
||||||
@@ -1631,6 +1901,8 @@ pub async fn player_preload_upcoming(
|
|||||||
QueryParam::String(item.title.clone()),
|
QueryParam::String(item.title.clone()),
|
||||||
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
|
||||||
|
QueryParam::String(stream_url),
|
||||||
|
QueryParam::String(target_dir.clone()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1650,6 +1922,16 @@ pub async fn player_preload_upcoming(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick the pump so the queued preloads actually start; without this they'd
|
||||||
|
// only begin once some other download activity pumps the queue.
|
||||||
|
if queued_count > 0 {
|
||||||
|
let active_downloads = {
|
||||||
|
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
manager.get_active_downloads()
|
||||||
|
};
|
||||||
|
crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
|
||||||
|
}
|
||||||
|
|
||||||
info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped);
|
info!("[Preload] Result: queued={}, already_downloaded={}, skipped={}", queued_count, already_downloaded, skipped);
|
||||||
|
|
||||||
Ok(PreloadResult {
|
Ok(PreloadResult {
|
||||||
@@ -1671,6 +1953,7 @@ fn sanitize_filename(name: &str) -> String {
|
|||||||
|
|
||||||
/// Update SmartCache configuration
|
/// Update SmartCache configuration
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_set_cache_config(
|
pub async fn player_set_cache_config(
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
config: CacheConfig,
|
config: CacheConfig,
|
||||||
@@ -1682,6 +1965,7 @@ pub async fn player_set_cache_config(
|
|||||||
|
|
||||||
/// Get current SmartCache configuration
|
/// Get current SmartCache configuration
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_get_cache_config(
|
pub async fn player_get_cache_config(
|
||||||
smart_cache: State<'_, SmartCacheWrapper>,
|
smart_cache: State<'_, SmartCacheWrapper>,
|
||||||
) -> Result<CacheConfig, String> {
|
) -> Result<CacheConfig, String> {
|
||||||
@@ -1691,11 +1975,13 @@ pub async fn player_get_cache_config(
|
|||||||
|
|
||||||
/// Configure Jellyfin API client for automatic playback reporting
|
/// Configure Jellyfin API client for automatic playback reporting
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_configure_jellyfin(
|
pub async fn player_configure_jellyfin(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
server_url: String,
|
server_url: String,
|
||||||
access_token: String,
|
access_token: String,
|
||||||
_user_id: String,
|
user_id: String,
|
||||||
device_id: String,
|
device_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
|
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
|
||||||
@@ -1706,17 +1992,37 @@ pub async fn player_configure_jellyfin(
|
|||||||
device_id,
|
device_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = JellyfinClient::new(config)?;
|
// Legacy client (used for remote session control / casting).
|
||||||
|
let client = JellyfinClient::new(config.clone())?;
|
||||||
|
|
||||||
|
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
|
||||||
|
// actually report through. Without this, Start/Progress/Stopped never reach
|
||||||
|
// Jellyfin, so playback position never syncs and you can't resume on another
|
||||||
|
// device. The reporter shares the player controller's Arc, so populating it
|
||||||
|
// here lights up reporting on both desktop and Android, on every auth path
|
||||||
|
// that configures the player (login / restore / reauth).
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
let reporter_client = JellyfinClient::new(config)?;
|
||||||
|
let reporter = crate::playback_reporting::PlaybackReporter::new(
|
||||||
|
db_service,
|
||||||
|
Arc::new(TokioMutex::new(Some(reporter_client))),
|
||||||
|
user_id,
|
||||||
|
);
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.set_jellyfin_client(Some(client));
|
controller.set_jellyfin_client(Some(client));
|
||||||
|
controller.set_playback_reporter(Some(reporter)).await;
|
||||||
|
|
||||||
log::info!("[PlayerCommand] Jellyfin client configured successfully");
|
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable Jellyfin automatic playback reporting
|
/// Disable Jellyfin automatic playback reporting
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_disable_jellyfin(
|
pub async fn player_disable_jellyfin(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -1724,13 +2030,105 @@ pub async fn player_disable_jellyfin(
|
|||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.set_jellyfin_client(None);
|
controller.set_jellyfin_client(None);
|
||||||
|
controller.set_playback_reporter(None).await;
|
||||||
|
|
||||||
log::info!("[PlayerCommand] Jellyfin client disabled");
|
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// Queue items enqueued as Remote must flip to Local once a completed
|
||||||
|
/// download exists on disk — this is what makes preloaded tracks (and
|
||||||
|
/// offline playback after a connection drop) actually use the cache.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_refresh_queue_local_sources_switches_completed_downloads() {
|
||||||
|
use super::{refresh_queue_local_sources, DatabaseWrapper};
|
||||||
|
use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
|
||||||
|
use crate::storage::Database;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
// A real file on disk for the completed download; a missing file for
|
||||||
|
// the second entry to prove nonexistent files are not switched.
|
||||||
|
let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let existing = dir.join("track-a.mp3");
|
||||||
|
std::fs::write(&existing, b"audio").unwrap();
|
||||||
|
let missing = dir.join("track-b-missing.mp3");
|
||||||
|
let _ = std::fs::remove_file(&missing);
|
||||||
|
|
||||||
|
let database = Database::open_in_memory().unwrap();
|
||||||
|
{
|
||||||
|
let conn = database.connection();
|
||||||
|
let conn = conn.lock().unwrap();
|
||||||
|
conn.execute_batch(&format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
|
||||||
|
INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
|
||||||
|
INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||||
|
VALUES ('track-a', 'user1', '{}', 'completed');
|
||||||
|
INSERT INTO downloads (item_id, user_id, file_path, status)
|
||||||
|
VALUES ('track-b', 'user1', '{}', 'completed');
|
||||||
|
"#,
|
||||||
|
existing.display(),
|
||||||
|
missing.display()
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let db = DatabaseWrapper(Mutex::new(database));
|
||||||
|
|
||||||
|
let make_item = |id: &str| MediaItem {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: id.to_string(),
|
||||||
|
name: None,
|
||||||
|
artist: None,
|
||||||
|
album: None,
|
||||||
|
album_name: None,
|
||||||
|
album_id: None,
|
||||||
|
artist_items: None,
|
||||||
|
artists: None,
|
||||||
|
primary_image_tag: None,
|
||||||
|
item_type: None,
|
||||||
|
playlist_id: None,
|
||||||
|
duration: None,
|
||||||
|
artwork_url: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: format!("http://test/Audio/{}/stream", id),
|
||||||
|
jellyfin_item_id: id.to_string(),
|
||||||
|
},
|
||||||
|
video_codec: None,
|
||||||
|
needs_transcoding: false,
|
||||||
|
video_width: None,
|
||||||
|
video_height: None,
|
||||||
|
subtitles: vec![],
|
||||||
|
series_id: None,
|
||||||
|
server_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller
|
||||||
|
.set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
|
||||||
|
assert_eq!(switched, 1, "only the download whose file exists switches");
|
||||||
|
|
||||||
|
let queue = controller.queue();
|
||||||
|
let queue_lock = queue.lock().unwrap();
|
||||||
|
match &queue_lock.items()[0].source {
|
||||||
|
MediaSource::Local { file_path, jellyfin_item_id } => {
|
||||||
|
assert_eq!(file_path, &existing);
|
||||||
|
assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
|
||||||
|
}
|
||||||
|
other => panic!("track-a should be local, got {:?}", other),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
|
||||||
|
"track-b's file is missing, it must stay remote"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test track index finding in album
|
/// Test track index finding in album
|
||||||
/// This reproduces the bug where clicking songs 1-5 always played song 13
|
/// This reproduces the bug where clicking songs 1-5 always played song 13
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1757,6 +2155,7 @@ mod tests {
|
|||||||
// Test finding track 1 (should be index 0)
|
// Test finding track 1 (should be index 0)
|
||||||
let index1 = tracks.iter().position(|t| t.id == "track1");
|
let index1 = tracks.iter().position(|t| t.id == "track1");
|
||||||
assert_eq!(index1, Some(0), "Track 1 should be at index 0");
|
assert_eq!(index1, Some(0), "Track 1 should be at index 0");
|
||||||
|
assert_eq!(tracks[0].name, "Song 1", "Track at index 0 should carry its name");
|
||||||
|
|
||||||
// Test finding track 3 (should be index 2)
|
// Test finding track 3 (should be index 2)
|
||||||
let index3 = tracks.iter().position(|t| t.id == "track3");
|
let index3 = tracks.iter().position(|t| t.id == "track3");
|
||||||
@@ -1830,6 +2229,7 @@ mod tests {
|
|||||||
|
|
||||||
// Verify all tracks are in correct order
|
// Verify all tracks are in correct order
|
||||||
assert_eq!(tracks[0].id, "id1");
|
assert_eq!(tracks[0].id, "id1");
|
||||||
|
assert_eq!(tracks[0].name, "Track 1", "Sorted track should retain its name");
|
||||||
assert_eq!(tracks[1].id, "id2");
|
assert_eq!(tracks[1].id, "id2");
|
||||||
assert_eq!(tracks[2].id, "id3");
|
assert_eq!(tracks[2].id, "id3");
|
||||||
assert_eq!(tracks[3].id, "id4");
|
assert_eq!(tracks[3].id, "id4");
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::repository::types::{ImageOptions, ImageType};
|
|||||||
use crate::repository::MediaRepository;
|
use crate::repository::MediaRepository;
|
||||||
|
|
||||||
/// Request to add items to queue
|
/// Request to add items to queue
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AddToQueueRequest {
|
pub struct AddToQueueRequest {
|
||||||
pub items: Vec<PlayItemRequest>,
|
pub items: Vec<PlayItemRequest>,
|
||||||
@@ -24,7 +24,7 @@ pub struct AddToQueueRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to add a track by ID - backend fetches metadata
|
/// Request to add a track by ID - backend fetches metadata
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AddTrackByIdRequest {
|
pub struct AddTrackByIdRequest {
|
||||||
pub track_id: String,
|
pub track_id: String,
|
||||||
@@ -32,7 +32,7 @@ pub struct AddTrackByIdRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Request to add multiple tracks by IDs - backend fetches metadata
|
/// Request to add multiple tracks by IDs - backend fetches metadata
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(specta::Type, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AddTracksByIdsRequest {
|
pub struct AddTracksByIdsRequest {
|
||||||
pub track_ids: Vec<String>,
|
pub track_ids: Vec<String>,
|
||||||
@@ -40,6 +40,7 @@ pub struct AddTracksByIdsRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_add_to_queue(
|
pub async fn player_add_to_queue(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -81,6 +82,7 @@ pub async fn player_add_to_queue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_remove_from_queue(
|
pub async fn player_remove_from_queue(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
index: usize,
|
index: usize,
|
||||||
@@ -107,6 +109,7 @@ pub async fn player_remove_from_queue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_move_in_queue(
|
pub async fn player_move_in_queue(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
from_index: usize,
|
from_index: usize,
|
||||||
@@ -137,6 +140,7 @@ pub async fn player_move_in_queue(
|
|||||||
|
|
||||||
/// Add a track to queue by ID - backend fetches metadata and constructs URLs
|
/// Add a track to queue by ID - backend fetches metadata and constructs URLs
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_add_track_by_id(
|
pub async fn player_add_track_by_id(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -236,6 +240,7 @@ pub async fn player_add_track_by_id(
|
|||||||
|
|
||||||
/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
|
/// Add multiple tracks to queue by IDs - backend fetches metadata and constructs URLs
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_add_tracks_by_ids(
|
pub async fn player_add_tracks_by_ids(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
db: State<'_, DatabaseWrapper>,
|
db: State<'_, DatabaseWrapper>,
|
||||||
@@ -339,12 +344,19 @@ pub async fn player_add_tracks_by_ids(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
pub async fn player_skip_to(
|
pub async fn player_skip_to(
|
||||||
player: State<'_, PlayerStateWrapper>,
|
player: State<'_, PlayerStateWrapper>,
|
||||||
|
db: State<'_, DatabaseWrapper>,
|
||||||
index: usize,
|
index: usize,
|
||||||
) -> Result<PlayerStatus, String> {
|
) -> Result<PlayerStatus, String> {
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
|
|
||||||
|
// Prefer downloads that completed since the queue was built
|
||||||
|
if let Err(e) = super::refresh_queue_local_sources(&controller, &db).await {
|
||||||
|
log::warn!("[player_skip_to] Failed to refresh local sources: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
// Skip to the index and get the item to play
|
// Skip to the index and get the item to play
|
||||||
let item = {
|
let item = {
|
||||||
let queue = controller.queue();
|
let queue = controller.queue();
|
||||||
|
|||||||