Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9350c949 | ||
|
|
d01c1216b8 | ||
|
|
fb967433f0 | ||
|
|
ee584aced2 | ||
|
|
eb76c96e94 | ||
|
|
c3ead64748 | ||
|
|
742ad88a29 | ||
|
|
d4e2cd120c | ||
|
|
c543f90ad3 | ||
|
|
589f08b873 | ||
|
|
e2c9d68311 | ||
|
|
57b24f8c74 | ||
|
|
6391720d23 | ||
|
|
90f03dd142 | ||
|
|
514e42fccb | ||
|
|
9b1c9b3c91 | ||
|
|
1780109fb1 | ||
|
|
3a18ad060b | ||
|
|
1968c06172 | ||
|
|
ec8a7610f5 | ||
|
|
93d198ce21 | ||
|
|
2b42b74912 | ||
|
|
7660a33dfc | ||
|
|
3e962a202c | ||
|
|
43ddc5a889 | ||
|
|
772e9ca6d5 | ||
|
|
55fa26377a | ||
|
|
f89b241ad6 | ||
|
|
8b028b6b60 | ||
|
|
dd9d4191f1 | ||
|
|
bacb9ca0bb | ||
|
|
cf9472f04f | ||
|
|
f25deba824 | ||
|
|
8f4f651bac | ||
|
|
c175378f38 | ||
|
|
e083b53ee8 | ||
|
|
8f8433eebe | ||
|
|
6f057ad14a |
@@ -49,6 +49,13 @@ jobs:
|
||||
run: |
|
||||
bun install
|
||||
|
||||
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
||||
# multi-type includeItemTypes query defining a category in the frontend).
|
||||
# See scripts/check-frontend-boundary.sh and
|
||||
# docs/specs/scoped-search-boundary.md.
|
||||
- name: Check frontend/backend boundary
|
||||
run: bash scripts/check-frontend-boundary.sh
|
||||
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
bunx svelte-kit sync
|
||||
|
||||
@@ -121,6 +121,64 @@ jobs:
|
||||
path: dist/linux/
|
||||
retention-days: 30
|
||||
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
|
||||
# baked into the builder image. No toolchain installs here — the image has
|
||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cache/cargo-xwin
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-windows-
|
||||
|
||||
- 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: Set app version from tag
|
||||
run: |
|
||||
# On a tag build the tag is the single source of truth for the version.
|
||||
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting version to $VERSION"
|
||||
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
|
||||
fi
|
||||
grep '"version"' src-tauri/tauri.conf.json
|
||||
|
||||
- name: Build Windows (NSIS installer + exe)
|
||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||
|
||||
- name: List Windows artifacts
|
||||
run: ls -lah dist/windows/
|
||||
|
||||
- name: Upload Windows build artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: dist/windows/
|
||||
retention-days: 30
|
||||
|
||||
build-android:
|
||||
name: Build Android
|
||||
runs-on: linux/amd64
|
||||
@@ -239,7 +297,7 @@ jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: linux/amd64
|
||||
needs: [build-linux, build-android]
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
@@ -259,6 +317,12 @@ jobs:
|
||||
name: jellytau-linux
|
||||
path: artifacts/linux/
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: jellytau-windows
|
||||
path: artifacts/windows/
|
||||
|
||||
- name: Download Android artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -277,6 +341,9 @@ jobs:
|
||||
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
|
||||
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Windows" >> release_notes.md
|
||||
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "#### Android" >> release_notes.md
|
||||
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
|
||||
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
|
||||
@@ -358,7 +425,7 @@ jobs:
|
||||
fi
|
||||
echo "Release id=$RELEASE_ID"
|
||||
|
||||
for f in artifacts/android/* artifacts/linux/*; do
|
||||
for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "⬆️ Uploading $(basename "$f")"
|
||||
curl -fsS -X POST \
|
||||
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git checkout -q -b gitea-pages
|
||||
git add -A
|
||||
git commit -q -m "docs: publish site from ${GITHUB_SHA::8}"
|
||||
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
|
||||
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
|
||||
git commit -q -m "docs: publish site from ${SHORT_SHA}"
|
||||
echo "🚀 Force-pushing to gitea-pages"
|
||||
git push -f "$REMOTE" gitea-pages
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
name: Requirement Traceability Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
traceability:
|
||||
name: Validate Requirement Traces
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
|
||||
# action needed — fetching it stalls on this Gitea runner.
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Extract requirement traces
|
||||
run: bun run traces:json > traces.json
|
||||
|
||||
- name: Validate trace format
|
||||
run: |
|
||||
if ! jq empty traces.json 2>/dev/null; then
|
||||
echo "❌ Invalid traces.json format"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Traces JSON is valid"
|
||||
|
||||
- name: Check requirement coverage
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Extract coverage stats
|
||||
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
|
||||
UR_COUNT=$(jq '.byType.UR | length' traces.json)
|
||||
IR_COUNT=$(jq '.byType.IR | length' traces.json)
|
||||
DR_COUNT=$(jq '.byType.DR | length' traces.json)
|
||||
JA_COUNT=$(jq '.byType.JA | length' traces.json)
|
||||
|
||||
echo "## 📊 Requirement Traceability Report"
|
||||
echo ""
|
||||
echo "**Total TRACES Found:** $TOTAL_TRACES"
|
||||
echo ""
|
||||
echo "### Requirements Covered:"
|
||||
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
|
||||
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
|
||||
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
|
||||
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
|
||||
echo ""
|
||||
|
||||
# Set minimum coverage threshold (50%)
|
||||
TOTAL_REQS=114
|
||||
MIN_COVERAGE=$((TOTAL_REQS / 2))
|
||||
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
|
||||
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
|
||||
|
||||
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
|
||||
echo ""
|
||||
|
||||
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
|
||||
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
|
||||
fi
|
||||
|
||||
- name: Check for new untraced code
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Find files modified in this PR/push
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
else
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "✅ No source files changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "### Files Changed:"
|
||||
echo "$CHANGED_FILES" | sed 's/^/- /'
|
||||
echo ""
|
||||
|
||||
# Check if changed files have TRACES
|
||||
UNTRACED_FILES=""
|
||||
while IFS= read -r file; do
|
||||
if [ -f "$file" ]; then
|
||||
# Skip test files and generated code
|
||||
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if file has TRACES comments
|
||||
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
|
||||
UNTRACED_FILES+="$file"$'\n'
|
||||
fi
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ -n "$UNTRACED_FILES" ]; then
|
||||
echo "⚠️ New files without TRACES:"
|
||||
echo "$UNTRACED_FILES" | sed 's/^/ - /'
|
||||
echo ""
|
||||
echo "💡 Add TRACES comments to link code to requirements:"
|
||||
echo " // TRACES: UR-001, UR-002 | DR-003"
|
||||
else
|
||||
echo "✅ All changed files have TRACES comments"
|
||||
fi
|
||||
|
||||
- name: Generate traceability report
|
||||
if: always()
|
||||
run: bun run traces:markdown
|
||||
|
||||
- name: Upload traceability report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: traceability-report
|
||||
path: docs/traceability.md
|
||||
retention-days: 30
|
||||
|
||||
- name: Comment PR with coverage report
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
|
||||
|
||||
const urCount = traces.byType.UR.length;
|
||||
const irCount = traces.byType.IR.length;
|
||||
const drCount = traces.byType.DR.length;
|
||||
const jaCount = traces.byType.JA.length;
|
||||
const total = urCount + irCount + drCount + jaCount;
|
||||
const coverage = Math.round((total / 114) * 100);
|
||||
|
||||
const comment = `## 📊 Requirement Traceability Report
|
||||
|
||||
**Coverage:** ${coverage}% (${total}/114 requirements traced)
|
||||
|
||||
### By Type:
|
||||
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
|
||||
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
|
||||
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
|
||||
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
|
||||
|
||||
**Total Traces:** ${traces.totalTraces}
|
||||
|
||||
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
@@ -35,10 +35,26 @@ CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
|
||||
only against the mirror if one exists; the canonical remote is
|
||||
`gitea.tourolle.paris`.
|
||||
|
||||
> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
|
||||
> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
|
||||
> CI workflow step. Every build, test, and packaging **tool** must already live
|
||||
> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
|
||||
> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
|
||||
> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
|
||||
> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
|
||||
> it from CI — do not install it at job time. This keeps builds reproducible and
|
||||
> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
|
||||
>
|
||||
> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
|
||||
> a violation — that's project dependencies, not a toolchain. The rule is about
|
||||
> system tools, not npm/bun/cargo *packages* declared by the project.
|
||||
|
||||
## Before Committing
|
||||
|
||||
- Frontend: `bun run check` and `bun run test` must pass.
|
||||
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
|
||||
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
|
||||
item-type category sets) leaked into the frontend. See below.
|
||||
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
|
||||
comment (see below).
|
||||
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
|
||||
@@ -161,6 +177,25 @@ and [docs/build-release.md](docs/build-release.md).
|
||||
- **Graceful backend init.** If a native player backend fails to initialize, the
|
||||
app falls back to a no-op backend and emits `backend-init-failed` rather than
|
||||
crashing.
|
||||
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
|
||||
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
|
||||
category like "Music". Send an opaque scope/enum across the boundary and let the
|
||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
||||
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
|
||||
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
|
||||
assignment. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
|
||||
for the incident this rule came from.
|
||||
|
||||
## Writing specs
|
||||
|
||||
New feature specs go in [docs/specs/](docs/specs/). **Start from
|
||||
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
|
||||
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
|
||||
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
|
||||
Before accepting a spec, run it past
|
||||
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
|
||||
a spec around "no Rust changes required" — correct layer placement is the goal,
|
||||
not minimal backend churn.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -231,6 +266,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
||||
|
||||
## Testing
|
||||
|
||||
### 🔴 Bug fixes: failing test FIRST, then the fix
|
||||
|
||||
When fixing a bug, **write a test that reproduces it and watch it fail before
|
||||
touching the fix.** Red → green, in that order:
|
||||
|
||||
1. Write a test that exercises the broken behavior and **run it — it must fail**,
|
||||
proving the test actually catches the bug (a test that passes before the fix
|
||||
proves nothing).
|
||||
2. Apply the fix.
|
||||
3. Re-run — the test now passes, and so does the rest of the suite.
|
||||
|
||||
Never fix first and backfill the test afterward: a test written against
|
||||
already-fixed code can pass for the wrong reason and silently fails to guard the
|
||||
regression. If the logic is buried in a component, extract the pure part into a
|
||||
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
|
||||
pattern as `TrackList.logic.test.ts`.
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cd src-tauri && cargo test
|
||||
|
||||
+31
@@ -1,4 +1,11 @@
|
||||
# Multi-stage build for JellyTau - Tauri Jellyfin client
|
||||
#
|
||||
# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
|
||||
# the unified registry builder image, which carries every packaging tool. Declared
|
||||
# here (before the first FROM) so it's in scope for those stages' FROM lines.
|
||||
# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
|
||||
ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
|
||||
|
||||
FROM ubuntu:24.04 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -108,6 +115,30 @@ RUN cd src-tauri && cargo fetch && cd .. && \
|
||||
bun run tauri android build --apk true && \
|
||||
echo "APK build complete!"
|
||||
|
||||
# Desktop packaging stages build FROM the unified registry builder image (see the
|
||||
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
|
||||
# (rpm/file for Linux, mingw-w64 + nsis + the x86_64-pc-windows-gnu rust target
|
||||
# for Windows). ONE source of dependency truth, shared with CI — no per-stage
|
||||
# apt/rustup here.
|
||||
|
||||
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
|
||||
# Thin layer over the builder — the actual build runs at container-run time on
|
||||
# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
|
||||
# matching the `dev` service model. Run standalone with:
|
||||
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" <img> \
|
||||
# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
FROM ${BUILDER_IMAGE} AS desktop-linux-build
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
||||
|
||||
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
||||
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
|
||||
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
||||
# exe-only. Build runs at container-run time like above.
|
||||
FROM ${BUILDER_IMAGE} AS windows-cross
|
||||
WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
|
||||
|
||||
# Final output stage
|
||||
FROM ubuntu:24.04 AS final
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# JellyTau Arch Linux package builder.
|
||||
#
|
||||
# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
|
||||
# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
|
||||
# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
|
||||
#
|
||||
# docker build -f Dockerfile.arch -t jellytau-arch .
|
||||
# docker run --rm -v "$PWD/dist:/out" jellytau-arch
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm \
|
||||
base-devel git sudo \
|
||||
rust cargo nodejs \
|
||||
webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
|
||||
libsoup3 pkgconf openssl \
|
||||
&& pacman -Scc --noconfirm
|
||||
|
||||
# Bun is not in the official repos; install the upstream binary.
|
||||
RUN curl -fsSL https://bun.sh/install | bash && \
|
||||
ln -s /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
# Non-root build user with passwordless sudo for makepkg's dependency step.
|
||||
RUN useradd -m builder && \
|
||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
|
||||
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN chown -R builder:builder /app
|
||||
|
||||
USER builder
|
||||
ENV OUTPUT_DIR=/out
|
||||
RUN mkdir -p /out
|
||||
VOLUME ["/out"]
|
||||
|
||||
# Default: build the package. Output lands in /out (mount it to collect the pkg).
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
|
||||
+33
-1
@@ -1,5 +1,9 @@
|
||||
# JellyTau Builder Image
|
||||
# Pre-built image with all dependencies for building and testing
|
||||
# Pre-built image with all dependencies for building, testing, and packaging:
|
||||
# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
|
||||
# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
|
||||
# Arch packages build in a separate archlinux image (Dockerfile.arch) since
|
||||
# makepkg is Arch-specific.
|
||||
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
|
||||
|
||||
FROM ubuntu:24.04
|
||||
@@ -83,6 +87,34 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
||||
# Set NDK environment variable
|
||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||
# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
|
||||
# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
|
||||
rpm \
|
||||
file \
|
||||
# Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
|
||||
# clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
|
||||
# C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
|
||||
# nsis = installer generator.
|
||||
clang \
|
||||
lld \
|
||||
llvm \
|
||||
nsis \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
|
||||
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
|
||||
# so provide it as a symlink.
|
||||
&& ln -sf /usr/bin/clang /usr/local/bin/clang-cl
|
||||
|
||||
# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
|
||||
RUN . $HOME/.cargo/env && \
|
||||
rustup target add x86_64-pc-windows-msvc && \
|
||||
cargo install --locked cargo-xwin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@wdio/cli": "^9.5.0",
|
||||
"@wdio/local-runner": "^9.5.0",
|
||||
@@ -30,7 +31,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": ">=1.0.0 <5.0.0",
|
||||
"webdriverio": "^9.5.0",
|
||||
},
|
||||
},
|
||||
@@ -46,10 +47,18 @@
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
@@ -348,6 +357,8 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
|
||||
@@ -362,7 +373,7 @@
|
||||
|
||||
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
|
||||
|
||||
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
|
||||
|
||||
@@ -422,6 +433,8 @@
|
||||
|
||||
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
|
||||
@@ -498,6 +511,8 @@
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
@@ -686,6 +701,8 @@
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
@@ -738,6 +755,12 @@
|
||||
|
||||
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
|
||||
|
||||
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
|
||||
|
||||
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
|
||||
|
||||
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
|
||||
|
||||
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
@@ -756,7 +779,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
@@ -828,6 +851,10 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -1020,7 +1047,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
|
||||
|
||||
@@ -1042,7 +1069,7 @@
|
||||
|
||||
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
|
||||
|
||||
@@ -1068,7 +1095,7 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
|
||||
|
||||
@@ -1166,6 +1193,10 @@
|
||||
|
||||
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
|
||||
|
||||
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
@@ -1190,10 +1221,24 @@
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
|
||||
|
||||
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
@@ -1260,6 +1305,8 @@
|
||||
|
||||
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
|
||||
|
||||
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
|
||||
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
|
||||
@@ -1298,6 +1345,12 @@
|
||||
|
||||
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
|
||||
|
||||
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
|
||||
|
||||
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
@@ -1328,7 +1381,7 @@
|
||||
|
||||
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
"@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
@@ -1338,34 +1391,24 @@
|
||||
|
||||
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
@@ -1432,8 +1475,6 @@
|
||||
|
||||
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
@@ -33,6 +33,58 @@ services:
|
||||
ports:
|
||||
- "5172:5172" # In case you want to run dev server
|
||||
|
||||
# Linux desktop packages - deb + rpm + pacman into ./dist
|
||||
desktop-linux-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: desktop-linux-build
|
||||
args:
|
||||
# Defaults to the registry builder (Dockerfile's ARG). Point at a locally
|
||||
# built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-desktop-linux-build
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
|
||||
|
||||
# Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
|
||||
arch-build:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.arch
|
||||
container_name: jellytau-arch-build
|
||||
volumes:
|
||||
- ./dist:/out
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/out
|
||||
|
||||
# Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
|
||||
# ./dist. Override WIN_BUNDLES=none for exe-only.
|
||||
windows-cross:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: windows-cross
|
||||
args:
|
||||
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
|
||||
container_name: jellytau-windows-cross
|
||||
volumes:
|
||||
- .:/app
|
||||
- cargo-cache:/root/.cargo
|
||||
- bun-cache:/root/.bun
|
||||
environment:
|
||||
- RUST_BACKTRACE=1
|
||||
- OUTPUT_DIR=/app/dist
|
||||
- WIN_BUNDLES=${WIN_BUNDLES:-nsis}
|
||||
command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
|
||||
|
||||
# Development container - for interactive development
|
||||
dev:
|
||||
build:
|
||||
|
||||
@@ -51,14 +51,19 @@ graph TD
|
||||
|
||||
**View Enforcement:**
|
||||
|
||||
Ordinal content (where position carries meaning) is always a list. Everything
|
||||
else honours the user's persisted grid/list preference — see
|
||||
[ux-flows.md §5A.2](../ux-flows.md).
|
||||
|
||||
| Content Type | View Mode | Toggle Visible | Component Used |
|
||||
|--------------|-----------|----------------|----------------|
|
||||
| Tracks | List (forced) | No | `TrackList` |
|
||||
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
|
||||
| Album Detail Tracks | List (forced) | No | `TrackList` |
|
||||
| Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Artists | User preference | Yes | `LibraryGrid` |
|
||||
| Albums | User preference | Yes | `LibraryGrid` |
|
||||
| Playlists | User preference | Yes | `LibraryGrid` |
|
||||
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
|
||||
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
|
||||
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
|
||||
|
||||
**TrackList Component:**
|
||||
|
||||
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
|
||||
/>
|
||||
```
|
||||
|
||||
**LibraryGrid forceGrid Prop:**
|
||||
**LibraryGrid view mode:**
|
||||
|
||||
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
|
||||
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
|
||||
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
|
||||
prop controls whether the toggle buttons appear in the page header; the grid
|
||||
itself always follows the stored preference.
|
||||
|
||||
A `forceGrid` prop previously existed to pin pages to grid regardless of
|
||||
preference. No caller ever passed it, so it was removed — pages that were
|
||||
documented as "forced grid" have in practice always honoured the toggle.
|
||||
|
||||
## Playback Reporting Service
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Desktop packaging (Linux, Arch, Windows)
|
||||
|
||||
How to produce distributable desktop packages for JellyTau. All three flows can
|
||||
run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
|
||||
|
||||
## One builder image (shared with CI)
|
||||
|
||||
The deb/rpm and Windows-cross flows build on the **unified registry builder**
|
||||
([../Dockerfile.builder](../Dockerfile.builder) →
|
||||
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
|
||||
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
|
||||
(Windows). There is **one** dependency source of truth — no per-stage tool
|
||||
installs.
|
||||
|
||||
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
|
||||
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
|
||||
your bind-mounted source (like the `dev` service), so source edits need no image
|
||||
rebuild.
|
||||
|
||||
**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
|
||||
first, or the packaging flows use the stale registry image:
|
||||
|
||||
```bash
|
||||
scripts/build-builder-image.sh # build + push :latest to the registry
|
||||
# ...or iterate locally without pushing:
|
||||
docker build -f Dockerfile.builder -t jellytau-builder:latest .
|
||||
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
|
||||
```
|
||||
|
||||
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
|
||||
because `makepkg` is Arch-specific — it is not part of the unified builder.
|
||||
|
||||
| Target | Format | Docker command | Functional? |
|
||||
|--------|--------|----------------|-------------|
|
||||
| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
|
||||
| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
|
||||
| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
|
||||
|
||||
## Linux: deb + rpm
|
||||
|
||||
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
|
||||
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
|
||||
|
||||
```bash
|
||||
bun run docker:build:linux # deb + rpm -> ./dist
|
||||
# or, on a host with the Tauri Linux deps installed:
|
||||
BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
|
||||
```
|
||||
|
||||
Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
|
||||
transcoded video). The deb/rpm declare these.
|
||||
|
||||
> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
|
||||
> add it to `BUNDLES`.
|
||||
|
||||
## Arch Linux: pacman package
|
||||
|
||||
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
|
||||
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
|
||||
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
|
||||
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
|
||||
|
||||
```bash
|
||||
bun run docker:build:arch # .pkg.tar.zst -> ./dist
|
||||
```
|
||||
|
||||
The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
|
||||
publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
|
||||
|
||||
`makepkg` refuses to run as root, so the Docker stage builds as a non-root
|
||||
`builder` user. Because the image `COPY`s the source at build time, the
|
||||
`arch-build` compose service does **not** bind-mount the repo — rebuild the image
|
||||
to pick up source changes.
|
||||
|
||||
## Windows: NSIS installer cross-compiled from Linux
|
||||
|
||||
Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
|
||||
cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
|
||||
Video plays via WebView2 and audio via the webview `<audio>` backend. See
|
||||
[build-windows.md](build-windows.md) for the full explanation.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
The Docker `windows-cross` stage is a thin layer over the builder, which carries
|
||||
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
|
||||
Cross-compilation is Tauri's "last resort" path (less tested than building on
|
||||
Windows); a `windows-latest` CI job is the fallback if it misbehaves.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Windows build
|
||||
|
||||
JellyTau targets Linux and Android primarily, but a working Windows build —
|
||||
including an **NSIS installer cross-compiled from Linux** — is produced by the
|
||||
Docker tooling. It is not yet a first-class release target (no code signing / CI
|
||||
job / SMTC lockscreen), but it runs and plays media.
|
||||
|
||||
## How playback works on Windows
|
||||
|
||||
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
|
||||
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
|
||||
## Cross-compiling from Linux (MSVC + cargo-xwin)
|
||||
|
||||
We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
|
||||
the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
|
||||
[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
|
||||
CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
|
||||
officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
|
||||
Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
|
||||
> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
|
||||
> gates `--bundles` by the host OS unless it recognizes a real Windows build.
|
||||
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
|
||||
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
|
||||
```
|
||||
|
||||
Or directly on a host that has the toolchain:
|
||||
|
||||
```bash
|
||||
scripts/build-windows-cross.sh # nsis installer + exe
|
||||
WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
|
||||
```
|
||||
|
||||
Under the hood the build runs:
|
||||
|
||||
```bash
|
||||
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
|
||||
```
|
||||
|
||||
Outputs:
|
||||
- `.exe` — `src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
|
||||
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
|
||||
|
||||
(both copied to `./dist` when `OUTPUT_DIR` is set).
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
|
||||
than building on Windows. If it misbehaves, a `windows-latest` CI job or a
|
||||
Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
|
||||
- **Code signing is not wired up** — the installer is unsigned, so Windows
|
||||
SmartScreen will warn on first run.
|
||||
|
||||
## Outstanding for a first-class Windows release
|
||||
|
||||
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
|
||||
path.
|
||||
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
|
||||
frontend; streaming works today.
|
||||
3. Code signing + a Windows packaging CI job.
|
||||
+74
-3
@@ -37,7 +37,7 @@ For a narrative overview of the system design, see
|
||||
| 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-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
|
||||
| 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 |
|
||||
@@ -58,6 +58,18 @@ For a narrative overview of the system design, see
|
||||
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
|
||||
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
|
||||
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
|
||||
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
|
||||
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
|
||||
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
|
||||
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
|
||||
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done |
|
||||
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
|
||||
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
|
||||
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
|
||||
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
|
||||
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +101,7 @@ External system integrations and platform-specific implementations.
|
||||
| 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-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
|
||||
| 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 |
|
||||
@@ -97,6 +109,7 @@ External system integrations and platform-specific implementations.
|
||||
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
|
||||
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
|
||||
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
|
||||
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
|
||||
|
||||
### 2.2 Jellyfin API Requirements
|
||||
|
||||
@@ -174,7 +187,7 @@ Internal architecture, components, and application logic.
|
||||
| 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-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
|
||||
| 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 |
|
||||
@@ -203,6 +216,32 @@ Internal architecture, components, and application logic.
|
||||
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
|
||||
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
|
||||
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
|
||||
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
|
||||
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
|
||||
| DR-063 | Search scope resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
|
||||
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
|
||||
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | Implemented |
|
||||
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
|
||||
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
|
||||
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
|
||||
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
|
||||
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
|
||||
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
|
||||
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
|
||||
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog` → `INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done |
|
||||
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done |
|
||||
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done |
|
||||
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
|
||||
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done |
|
||||
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done |
|
||||
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done |
|
||||
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
|
||||
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
|
||||
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
|
||||
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
|
||||
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
|
||||
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -259,6 +298,17 @@ Internal architecture, components, and application logic.
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
| UR-047 | IR-013 | DR-060 |
|
||||
| UR-048 | - | DR-061, DR-062 |
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087 |
|
||||
|
||||
---
|
||||
|
||||
@@ -329,6 +379,25 @@ Internal architecture, components, and application logic.
|
||||
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
|
||||
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
|
||||
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
|
||||
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
|
||||
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
|
||||
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
|
||||
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Done |
|
||||
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
|
||||
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 2–3 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
|
||||
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
|
||||
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
|
||||
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
|
||||
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
|
||||
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
|
||||
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
|
||||
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
|
||||
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
|
||||
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
|
||||
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
|
||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
||||
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
@@ -347,6 +416,8 @@ Internal architecture, components, and application logic.
|
||||
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
|
||||
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Spec review checklist
|
||||
|
||||
Run a spec past this before accepting it. It exists because JellyTau's
|
||||
backend/frontend boundary is a **stated rule with, historically, no gate** — the
|
||||
rule lived in the architecture docs, but nothing forced a spec author to check a
|
||||
new design against it, and a "minimal-change" spec quietly leaked domain
|
||||
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
|
||||
This checklist is the human gate. The CI check
|
||||
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
|
||||
signature — it does **not** replace this.
|
||||
|
||||
Copy the boxes into the review comment (or the PR) and tick them.
|
||||
|
||||
## Boundary (the one that bites)
|
||||
|
||||
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
|
||||
*logic*, not files. A spec without this section is not ready to review.
|
||||
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
|
||||
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
|
||||
query-shaping rules, business rules, reachability/sync policy. If the
|
||||
frontend names a *set* of item types to define a category, that is a leak —
|
||||
it belongs behind an opaque enum the backend expands.
|
||||
- [ ] **"The backend already accepts this parameter" was not used as the reason**
|
||||
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
|
||||
the decision of its value.
|
||||
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
|
||||
change."** "Frontend only, no Rust changes" is a description, never a goal.
|
||||
The goal is *correct layer placement*; sometimes that is more Rust work.
|
||||
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
|
||||
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?* →
|
||||
frontend. Borderline defaults to Rust.
|
||||
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
|
||||
is **not** over-corrected into the backend. The rule targets category
|
||||
*taxonomy*, not every mention of a type. Don't invent a backend enum per
|
||||
list page.
|
||||
|
||||
## IPC contract
|
||||
|
||||
- [ ] Anything crossing the boundary has its wire shape specified.
|
||||
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
|
||||
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
|
||||
sides; events are kebab-case. (CLAUDE.md §IPC,
|
||||
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
|
||||
- [ ] Any result that arrives *twice* (command return **and** a later event —
|
||||
e.g. the search cache/server merge) has **both** payloads in the new shape.
|
||||
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
|
||||
|
||||
## Requirements & traceability
|
||||
|
||||
- [ ] Linked to existing URs, or new URs/DRs are allocated in
|
||||
[requirements.md](../requirements.md).
|
||||
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
|
||||
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
|
||||
|
||||
## Conflicts & hygiene
|
||||
|
||||
- [ ] If this spec revises/supersedes another, the older spec gets a banner
|
||||
pointing here — no two specs silently contradicting.
|
||||
- [ ] Acceptance criteria include the standard gates: `bun run check`,
|
||||
`bun run test`, `bun run check:boundary`, and (if Rust changed)
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
|
||||
- [ ] Notes flag that a parallel Claude session may be active in the repo.
|
||||
|
||||
---
|
||||
|
||||
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
|
||||
assignment first. Every other section can be negotiated; that one is the whole
|
||||
reason this file exists.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Spec: <feature name>
|
||||
|
||||
<!--
|
||||
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
|
||||
comments as you go. The section that matters most for this project is
|
||||
"Layer assignment" — read its comment before writing it.
|
||||
|
||||
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
|
||||
-->
|
||||
|
||||
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
|
||||
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
|
||||
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
|
||||
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- 2–4 sentences. What changes for the user, in plain terms. -->
|
||||
|
||||
## Motivation
|
||||
|
||||
<!-- Why now. The problem being solved. -->
|
||||
|
||||
## Layer assignment
|
||||
|
||||
<!--
|
||||
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
|
||||
do NOT reframe it as "how little backend work can we get away with."
|
||||
|
||||
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
|
||||
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
|
||||
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
|
||||
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
|
||||
navigation, view/order preferences, input handling.
|
||||
|
||||
For each distinct piece of *logic* this feature introduces, put it in the table
|
||||
and name the layer it belongs to and WHY. "It's less work in the frontend" and
|
||||
"the backend already accepts this parameter" are NOT reasons to place logic in
|
||||
the frontend — the backend accepting a parameter does not make deciding that
|
||||
parameter's value a presentation concern.
|
||||
|
||||
Litmus test for "does this belong in Rust?": Would this logic have to change if
|
||||
Jellyfin changed its API, added an item type, or altered a business rule? If
|
||||
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
|
||||
yes (and only yes), it is presentation → frontend.
|
||||
|
||||
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
|
||||
means as a set of Jellyfin types) in the frontend because the backend already
|
||||
accepted an includeItemTypes filter. That was a boundary leak; see
|
||||
scoped-search-boundary.md. This section exists to catch that class of mistake
|
||||
at spec time, not in review three features later.
|
||||
-->
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
|
||||
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
|
||||
|
||||
<!--
|
||||
If a row is genuinely borderline, say so and give the tie-breaker you used.
|
||||
Borderline defaults to Rust for anything touching domain data or vocabulary.
|
||||
-->
|
||||
|
||||
## Design
|
||||
|
||||
<!--
|
||||
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
|
||||
- Command NAME must match the Rust fn name exactly.
|
||||
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
|
||||
- Nested struct fields need #[serde(rename_all = "camelCase")].
|
||||
- Events are kebab-case.
|
||||
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
|
||||
|
||||
Regenerate bindings.ts from Rust types; never hand-edit it.
|
||||
-->
|
||||
|
||||
## Out of scope
|
||||
|
||||
<!-- What this spec deliberately does NOT do. -->
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- Checkable statements. Include the standard gates: -->
|
||||
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
|
||||
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated if Rust types changed.
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
|
||||
|
||||
## TRACES
|
||||
|
||||
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
<!--
|
||||
- A parallel Claude session may be active in this repo — `git diff` before
|
||||
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
|
||||
- Anything else non-obvious.
|
||||
-->
|
||||
@@ -0,0 +1,164 @@
|
||||
# Spec: Account menu and global chrome availability
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend only. No Rust changes required.
|
||||
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
|
||||
[requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §1.2–1.4](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Account actions — Settings, Downloads, Display preferences, Sign out — are
|
||||
currently reachable **only from `/library/*`**. Move them into a single shared
|
||||
account menu anchored to the user's name, and make that menu available on every
|
||||
authenticated non-immersive screen.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user sitting on the home screen cannot open Settings or sign out. The bottom
|
||||
nav offers Home / Search / Library only, and the header that hosts those actions
|
||||
belongs to the library layout. The user has to guess that account actions live
|
||||
*inside* Library — an unrelated section — and navigate there first.
|
||||
|
||||
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
|
||||
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
|
||||
same two actions are found two different ways.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The header is not global.** It is defined in
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
|
||||
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
|
||||
all.
|
||||
|
||||
2. **`routeOwnsLayout`** in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
|
||||
`/library`, `/player/`, `/login` — those routes own their own full-height
|
||||
flex column. Everything else renders into the root scroller with the root's
|
||||
`BottomUi` below it.
|
||||
|
||||
3. **Bottom nav is Home / Search / Library only**
|
||||
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
|
||||
or account entry.
|
||||
|
||||
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
|
||||
Settings or Sign out.
|
||||
|
||||
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
|
||||
trigger.
|
||||
|
||||
6. **The mobile overflow menu already has the right contents** (Downloads,
|
||||
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
|
||||
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
|
||||
|
||||
7. **`viewMode` is already a persisted store** in
|
||||
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
|
||||
`localStorage`). The Display setting is a second view onto it — **no new
|
||||
state, no migration.**
|
||||
|
||||
## Design
|
||||
|
||||
### `AccountMenu` component (DR-075)
|
||||
|
||||
One component used by both breakpoints. Contents in fixed order:
|
||||
|
||||
```
|
||||
Signed in as <name> ← identity block, not interactive
|
||||
<server host>
|
||||
────────────────────────
|
||||
Downloads
|
||||
Settings
|
||||
Display ← grid/list preference
|
||||
────────────────────────
|
||||
Sign out ← destructive, last, after a divider
|
||||
```
|
||||
|
||||
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
|
||||
horizontal space is tight, the avatar (or initial) alone is acceptable; the
|
||||
name shows inside the open menu regardless.
|
||||
- **Same items, same order, both platforms.**
|
||||
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
|
||||
and focus return to the trigger on close.
|
||||
- Menu items are real links/buttons — keyboard reachable, correct roles,
|
||||
`aria-expanded` on the trigger.
|
||||
|
||||
"Display" may either navigate to the Settings Display section or expose the
|
||||
grid/list choice inline. Prefer navigating — it keeps one source of truth for
|
||||
preferences and avoids a nested control inside a dropdown.
|
||||
|
||||
### Global chrome (DR-076)
|
||||
|
||||
Make the header — and therefore the account menu — available on `/`, `/search`,
|
||||
and `/downloads`.
|
||||
|
||||
The cleanest route is to lift the header out of the library layout into a shared
|
||||
component rendered by the root layout, with the library layout consuming the
|
||||
same component rather than defining its own. **Do not duplicate the markup into
|
||||
each route.**
|
||||
|
||||
Constraints that must survive the change:
|
||||
|
||||
- `/player/*` and `/login` stay chrome-free.
|
||||
- `/settings` already owns its layout; it needs no account menu (the user is
|
||||
already there), but must not double up on chrome.
|
||||
- The root layout's flex/scroller structure is deliberate — the comments in
|
||||
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
|
||||
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
|
||||
own column. Preserve the scroll containment; a regression here reintroduces
|
||||
the "last row hidden behind the nav" bug called out in those comments.
|
||||
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
|
||||
`showBottomNav`) must be unchanged.
|
||||
|
||||
### Display section in Settings (DR-077)
|
||||
|
||||
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
|
||||
with the grid/list control bound to the existing `viewMode` store via
|
||||
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
|
||||
controls drive the same store, so they stay in sync for free.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the Settings page or reorganising its existing sections.
|
||||
- Multi-server / account switching (UR-047) — the identity block displays the
|
||||
active server but offers no switcher.
|
||||
- Changing the bottom nav's three destinations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
|
||||
without first navigating into Library.
|
||||
- [ ] Desktop and mobile show the same account menu items in the same order.
|
||||
- [ ] The username/avatar opens the menu; it is a real button with
|
||||
`aria-expanded`.
|
||||
- [ ] Sign out is last, after a divider, and still logs out + resets library
|
||||
state + redirects as it does today.
|
||||
- [ ] `/player/*` and `/login` remain chrome-free.
|
||||
- [ ] Settings has a Display section that changes grid/list, and the change is
|
||||
immediately reflected by the library page-header toggle (same store).
|
||||
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
|
||||
visibility on any route.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
|
||||
`/downloads` (now true) and `/player/*`, `/login` (still false).
|
||||
- `AccountMenu`: renders the documented items in order; trigger toggles
|
||||
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
|
||||
logout handler.
|
||||
- Display setting: writes through to the `viewMode` store and persists.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu` → `UR-054 | DR-075`,
|
||||
shell/header changes → `UR-054 | DR-076`, Settings Display section →
|
||||
`UR-054, UR-029 | DR-077`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §1.2–1.4](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The layout shell is subtle and the existing comments record real bugs that
|
||||
were fixed there. Read them before restructuring.
|
||||
- Another session may be active in this repo, including in
|
||||
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
|
||||
unexpected changes, and expect to coordinate on that file.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Spec: Audio equalizer
|
||||
|
||||
**Status:** Accepted
|
||||
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
||||
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||
**Supersedes / revises:** —
|
||||
|
||||
## Summary
|
||||
|
||||
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
|
||||
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
|
||||
gains, from a new block in Settings › Audio. On Linux the gains apply live via
|
||||
MPV's audio-filter chain; the settings persist and re-apply on the next track
|
||||
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
|
||||
no-op for now (documented parity gap, same as those three features).
|
||||
|
||||
## Motivation
|
||||
|
||||
UR-027 is one of the few still-unbuilt audio features. The audio-settings
|
||||
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
|
||||
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
|
||||
› Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
|
||||
ride that pipeline. The equalizer is the same shape: N more fields on
|
||||
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
|
||||
settings panel. No new command, no new state machine.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
|
||||
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
|
||||
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
|
||||
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
|
||||
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
|
||||
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
|
||||
|
||||
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
|
||||
in Rust because a preset is a set of band gains defined *by the band layout*,
|
||||
which is an engine property. The frontend only ever names a preset and renders
|
||||
the resulting gains; it never defines them.
|
||||
|
||||
## Design
|
||||
|
||||
### `AudioSettings` (Rust, `settings.rs`)
|
||||
|
||||
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
|
||||
struct attribute):
|
||||
|
||||
```rust
|
||||
/// Equalizer enabled. When false, no `af` EQ filter is applied.
|
||||
pub equalizer_enabled: bool,
|
||||
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
|
||||
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
|
||||
pub equalizer_bands: Vec<f32>,
|
||||
```
|
||||
|
||||
Fixed 10-band ISO layout (domain constant in `settings.rs`):
|
||||
|
||||
```rust
|
||||
pub const EQ_BANDS: [f32; 10] =
|
||||
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
|
||||
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||
```
|
||||
|
||||
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
|
||||
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
|
||||
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
|
||||
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
|
||||
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
|
||||
|
||||
### Presets (Rust, `settings.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
|
||||
|
||||
impl EqPreset {
|
||||
/// The 10-band gain curve (dB) for this preset.
|
||||
pub fn gains(&self) -> [f32; 10] { /* table */ }
|
||||
}
|
||||
```
|
||||
|
||||
Preset selection is a *frontend* convenience: tapping a chip sets
|
||||
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
|
||||
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
|
||||
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
|
||||
the numbers. (If exposing the whole table is awkward through specta, expose
|
||||
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
|
||||
|
||||
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
|
||||
|
||||
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
|
||||
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
|
||||
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
|
||||
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
|
||||
filter. Errors map to `PlayerError` like the gapless code.
|
||||
|
||||
### No new persistence table
|
||||
|
||||
`AudioSettings` is already round-tripped by the frontend settings store and
|
||||
re-pushed via `player_set_audio_settings` on change and on load. The two new
|
||||
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
||||
|
||||
### Wire summary
|
||||
|
||||
- Command names unchanged: `player_set_audio_settings`,
|
||||
`player_get_audio_settings` (now carry the EQ fields).
|
||||
- New (optional) read-only command for preset curves — kebab n/a (it's a
|
||||
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
|
||||
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
||||
- Per-track or per-library EQ profiles — one global profile only.
|
||||
- Automatic loudness/room correction; only manual bands + presets.
|
||||
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||
the missing `with_crossfade_clamped` call.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Settings › Audio has an Equalizer block: enable toggle, preset chips, 10
|
||||
band sliders with live dB readouts, instant-apply (no Save button).
|
||||
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
|
||||
band switches the highlighted preset to "Custom" (frontend-only label).
|
||||
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
|
||||
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
|
||||
and app restart; disabling clears the filter without affecting other audio.
|
||||
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
|
||||
- [ ] New requirement-implementing code carries `// TRACES:` comments.
|
||||
- [ ] `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
|
||||
clamps out-of-range gains and pads/truncates band length; serialization
|
||||
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
|
||||
preset returns a 10-length curve; Flat is all zeros.
|
||||
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
|
||||
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
|
||||
expected band array; editing a band flips the label to Custom; enable toggle
|
||||
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
|
||||
|
||||
## TRACES
|
||||
|
||||
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
|
||||
- MPV EQ filter application: `UR-027 | IR-020`
|
||||
- Settings EQ UI block: `UR-027 | DR-030`
|
||||
- Preset-curve command: `UR-027 | DR-030`
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- A parallel Claude session is active in this repo (it has touched
|
||||
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
|
||||
build scripts, and the Rust build is currently broken by its
|
||||
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
|
||||
anything you didn't write; keep EQ changes isolated to `settings.rs`,
|
||||
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
|
||||
`commands/player/settings.rs`, and the settings page.
|
||||
- Mirror the volume-normalization block in the settings page for the toggle +
|
||||
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
|
||||
MPV property handling.
|
||||
- Confirm the exact MPV filter name available in the linked libmpv
|
||||
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
|
||||
string; gate cleanly if unavailable.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Spec: Downloads as a browsable offline library
|
||||
|
||||
**Status:** Draft — ready to implement
|
||||
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
|
||||
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §7.2–7.7](../ux-flows.md).
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the flat Active/Completed download list with two views under
|
||||
`/downloads`:
|
||||
|
||||
1. **Downloaded** (default) — the library, filtered to what's on the device,
|
||||
using the *same* browse screens as online (grids, cards, detail pages).
|
||||
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
|
||||
showing only in-flight transfers.
|
||||
|
||||
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
|
||||
pages, a device total, and the remove confirmation.
|
||||
|
||||
## Motivation
|
||||
|
||||
A user who downloaded three seasons and two albums sees ~70 individual transfer
|
||||
rows today, with no grouping and no reuse of the library UI. "What do I have
|
||||
offline" and "what is downloading" are different questions crammed into one flat
|
||||
list. Browsing offline should feel exactly like browsing online.
|
||||
|
||||
## Background: verified current state
|
||||
|
||||
1. **The offline repository is already a browsable tree.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
|
||||
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
|
||||
least one downloaded child. `get_libraries`, `get_item`, and `search` all
|
||||
filter to downloaded content via CTEs. This is the data source for
|
||||
Downloaded; **do not build a new query layer.**
|
||||
|
||||
2. **The client cannot reach it independently.**
|
||||
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems` →
|
||||
`repositoryGetItems` always goes through the **hybrid** repository
|
||||
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
|
||||
server. There is no "offline only" browse path exposed. This is the one real
|
||||
backend gap (DR-082).
|
||||
|
||||
3. **Downloads page is a flat two-tab list.**
|
||||
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
|
||||
Completed tabs, one `DownloadItem` row per transfer, no browsing.
|
||||
|
||||
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
|
||||
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
|
||||
given. Downloaded browse is those components with an offline-scoped source.
|
||||
|
||||
5. **A related fallthrough bug is already tracked** (DR-080, another session):
|
||||
`HybridRepository::get_items` treats an empty offline result as a cache miss
|
||||
and falls through to the server. The offline-only browse path (DR-082) must
|
||||
**not** share that behaviour — an empty result there is authoritative "nothing
|
||||
downloaded here."
|
||||
|
||||
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
|
||||
Do not surface them as manual controls; do not loop `startDownload` from the
|
||||
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
|
||||
|
||||
## Design
|
||||
|
||||
### View split (DR-081)
|
||||
|
||||
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
|
||||
default. Transfers shows a count/badge only while transfers are active.
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
|
||||
does not start downloads.
|
||||
|
||||
### Offline-scoped browse source (DR-082, DR-083)
|
||||
|
||||
Add an explicit offline-only browse path so Downloaded never merges server
|
||||
results and never depends on reachability. Two viable shapes — pick per the
|
||||
codebase, do not do both:
|
||||
|
||||
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
|
||||
`_libraries`) that calls the offline repository directly, with a matching
|
||||
client method; or
|
||||
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
|
||||
that bypasses the hybrid merge and the empty→fallthrough behaviour.
|
||||
|
||||
Either way: an empty result is authoritative (do **not** reuse the DR-080
|
||||
fallthrough), and the path is available while the server is reachable (a user
|
||||
online still wants to browse their downloads).
|
||||
|
||||
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
|
||||
source. Omit libraries and containers with no downloaded content. Badge
|
||||
partially- vs fully-downloaded containers. Play uses the local file; remove is
|
||||
available at item / album / season / series level and removes a container from
|
||||
the browse when its last downloaded child goes.
|
||||
|
||||
### Transfers view (DR-084)
|
||||
|
||||
The existing list, filtered to in-flight rows only: downloading (with progress),
|
||||
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
|
||||
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
|
||||
this view — they appear in Downloaded. Empty state points at the library.
|
||||
|
||||
### Disk usage (DR-085, UR-056)
|
||||
|
||||
- **Source the bytes from the download manager** — it writes the files and can
|
||||
stat them. Aggregate to album/season/series subtotals and a device total.
|
||||
This is display + aggregation, **not** new tracking.
|
||||
- **Format once, consistently.** One shared formatter, human units, 2–3
|
||||
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
|
||||
it everywhere.
|
||||
- **Surface it in familiar places:** a secondary size label on the card and
|
||||
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
|
||||
that reconciles with the listed sum; a reclaim figure in the remove
|
||||
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
|
||||
- Sort/filter by size is a nice-to-have, not required for v1.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
|
||||
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
|
||||
that governs the *online offline-fallback* library; this is the dedicated
|
||||
Downloads surface. They should be consistent but are separate work.
|
||||
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
|
||||
on that behaviour here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
|
||||
- [ ] Downloaded lists only libraries/containers with downloaded content, using
|
||||
the same grids/cards/detail pages as online browsing.
|
||||
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
|
||||
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
|
||||
through to the server.
|
||||
- [ ] Play from Downloaded plays the local file.
|
||||
- [ ] Remove works at item/album/season/series level and updates the browse.
|
||||
- [ ] Transfers shows only in-flight rows with working controls; finished
|
||||
transfers move to Downloaded.
|
||||
- [ ] Each downloaded item/container shows its on-disk size; a device total is
|
||||
shown and reconciles with the sum; remove states the reclaim amount.
|
||||
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
|
||||
`cargo clippy` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
- Repository client: the offline-only browse path returns downloaded content and
|
||||
its containers, and an empty result does **not** trigger server fallthrough.
|
||||
- Downloaded view: libraries/containers with no downloads are omitted;
|
||||
partial/full container badging.
|
||||
- Transfers: only in-flight statuses render; a completed transfer disappears.
|
||||
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
|
||||
total reconciles with listed items.
|
||||
- If a Rust command is added, add the tauri IPC param-naming coverage per
|
||||
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
|
||||
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
|
||||
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §7.2–7.7](../ux-flows.md) first — behavioural spec; this is
|
||||
the implementation plan.
|
||||
- The offline repository already does the hard part. The main work is a clean
|
||||
offline-only client path and reusing the library components — resist
|
||||
rebuilding browse UI.
|
||||
- Another session is active in downloads/offline/connectivity code (DR-074,
|
||||
DR-078–080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
|
||||
and the repository layer; check `git diff` before repairing unexpected changes.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Spec: Remove Jellyfin-specific models from the frontend
|
||||
|
||||
> **Implementation status (branch `frontend-domain-model`, worktree
|
||||
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
|
||||
> classification* and *time units* no longer speak Jellyfin:
|
||||
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
|
||||
> `from_jellyfin` mapping. The model gained real distinctions the flat
|
||||
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
|
||||
> - Every catalog `item.type === "..."` → `item.kind` (0 remaining in `src/`).
|
||||
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
|
||||
> `formatDuration` takes ms; progress bars are unit-consistent.
|
||||
> - User-facing type badge → `kindLabel()`.
|
||||
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
|
||||
>
|
||||
> **Deferred (tracked, not done):**
|
||||
> - `primaryImageTag` → `imageId` rename (naming-only; ~40 sites across catalog +
|
||||
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
|
||||
> round-trip). Catalog `MediaItem` already has `imageId`.
|
||||
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
|
||||
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
|
||||
> signatures* in ticks; needs those commands to accept ms (phase 4).
|
||||
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
|
||||
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
|
||||
> consumers migrate; drop the dual-carried fields once nothing reads them.
|
||||
|
||||
**Status:** Partially implemented (catalog surface); see banner.
|
||||
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
|
||||
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
|
||||
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
|
||||
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
|
||||
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
|
||||
architecture/boundary migration.
|
||||
**Supersedes / revises:** none. Extends the boundary work started in
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
|
||||
*whole media model*.
|
||||
|
||||
## Summary
|
||||
|
||||
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
|
||||
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
|
||||
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
|
||||
specta into **36+ frontend files**, with **127 `item.type === "…"` string
|
||||
comparisons across 23 files** and two frontend utility modules
|
||||
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
|
||||
field conversion in the presentation layer.
|
||||
|
||||
This spec defines a **provider-neutral domain model**, owned by Rust, that the
|
||||
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
|
||||
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
|
||||
names — remains in `src/`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two concrete problems, one strategic:
|
||||
|
||||
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
|
||||
presentation-only and Rust owns the domain. Today the *domain model itself* is
|
||||
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
|
||||
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
|
||||
is domain knowledge in the wrong layer, 36 files deep.
|
||||
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
|
||||
rename fails silently at runtime with no compiler help, across 127 sites. Tick
|
||||
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
|
||||
should have already resolved.
|
||||
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
|
||||
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
|
||||
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
|
||||
shut.
|
||||
|
||||
## Layer assignment
|
||||
|
||||
| Logic / responsibility | Layer | Why it belongs there |
|
||||
|------------------------|-------|----------------------|
|
||||
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
|
||||
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"`→`Track`, `PremiereDate`→`releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
|
||||
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
|
||||
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
|
||||
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
|
||||
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
|
||||
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
|
||||
|
||||
**Borderline calls, resolved:**
|
||||
|
||||
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
|
||||
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
|
||||
subset of the new domain model, not a special case.
|
||||
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
|
||||
playback context, sessions) are **already domain-neutral** — they are *our*
|
||||
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
|
||||
`item_type`. Only the Jellyfin item-type strings move.
|
||||
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
|
||||
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
|
||||
self-contained — deferred to a late phase, not phase 1.
|
||||
|
||||
## Design
|
||||
|
||||
### Single canonical model, one location, isolated mappings
|
||||
|
||||
The domain model is defined **once**, in a dedicated top-level Rust module
|
||||
`src-tauri/src/domain/`, and is the single source of truth shared across the
|
||||
whole app:
|
||||
|
||||
```
|
||||
src-tauri/src/domain/
|
||||
media.rs canonical MediaItem, MediaKind, and the other media types
|
||||
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
|
||||
mod.rs re-exports
|
||||
| tauri-specta (export_typescript_bindings test)
|
||||
v
|
||||
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
|
||||
```
|
||||
|
||||
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
|
||||
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
|
||||
projection of it. There is no second hand-written copy in either language, so it
|
||||
cannot drift — "shared between frontend and backend" is realized by generation,
|
||||
not duplication.
|
||||
- **Mappings live beside the model, never in consumers.** All provider translation
|
||||
(`JellyfinItem` → `domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
|
||||
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
|
||||
touches the domain type. Adding a second provider later means a new
|
||||
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
|
||||
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
|
||||
is used by `player/`, `download/`, and `playback_mode/` too — it is not
|
||||
repository-specific.
|
||||
- The existing `JellyfinItem` DTO + `to_media_item()` in
|
||||
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
|
||||
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
|
||||
translation instead of copying `item_type` through.
|
||||
|
||||
### The domain model (Rust)
|
||||
|
||||
```rust
|
||||
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
Track, Album, Artist, Playlist, // music
|
||||
Movie, Series, Season, Episode, // video
|
||||
Person, // cast/crew
|
||||
Channel, Folder, // containers/live
|
||||
}
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: MediaKind, // was: type: String
|
||||
pub is_folder: bool,
|
||||
pub server_id: String,
|
||||
|
||||
// Times in milliseconds — NEVER ticks.
|
||||
pub duration_ms: Option<i64>, // was: run_time_ticks
|
||||
|
||||
// Image as a resolved identifier the frontend turns into a URL via the
|
||||
// existing image command — no raw Jellyfin tag semantics leak.
|
||||
pub image_id: Option<String>, // was: primary_image_tag
|
||||
pub backdrop_image_ids: Option<Vec<String>>,
|
||||
|
||||
pub overview: Option<String>,
|
||||
pub genres: Option<Vec<String>>,
|
||||
pub production_year: Option<i32>,
|
||||
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
|
||||
pub community_rating: Option<f64>,
|
||||
pub official_rating: Option<String>,
|
||||
|
||||
// Relationships — already neutral, kept.
|
||||
pub album_id: Option<String>, pub album_name: Option<String>,
|
||||
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
|
||||
pub artist_items: Option<Vec<ArtistItem>>,
|
||||
pub series_id: Option<String>, pub series_name: Option<String>,
|
||||
pub season_id: Option<String>, pub season_name: Option<String>,
|
||||
|
||||
// Ordinal position — rename off Jellyfin's index vocabulary.
|
||||
pub track_number: Option<i32>, // was: index_number
|
||||
pub disc_number: Option<i32>, // was: parent_index_number
|
||||
|
||||
pub user_data: Option<UserData>,
|
||||
pub media_streams: Option<Vec<MediaStream>>,
|
||||
pub media_sources: Option<Vec<MediaSource>>,
|
||||
pub people: Option<Vec<Person>>,
|
||||
}
|
||||
```
|
||||
|
||||
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
|
||||
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
|
||||
private to that module. Its `to_media_item()` — today a near-passthrough that
|
||||
copies `item_type` straight across — is enriched into the single, tested place
|
||||
that:
|
||||
|
||||
- classifies `item_type: String` → `MediaKind` (including the edge cases found in
|
||||
the audit: `"ChannelFolderItem"` → `Channel`/`Folder` by `is_folder`,
|
||||
`"TvChannel"` → `Channel`, `"Composer"/"Director"/"Writer"` → `Person`,
|
||||
`"Video"` → `Movie` or a video leaf). Unknown strings map to `Folder` or a new
|
||||
`Other` variant — **decide at implementation; must not panic.**
|
||||
- converts `run_time_ticks` → `duration_ms` (`ticks / 10_000`).
|
||||
- maps `PremiereDate` → `release_date`, image tags → image ids.
|
||||
|
||||
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
|
||||
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
|
||||
|
||||
### 🔴 The `search-event` / dual-payload rule applies again
|
||||
|
||||
Every path that returns `MediaItem` — command returns **and** the `search-event`
|
||||
and any other event payloads — emits the new domain shape. Both sides of a
|
||||
twice-delivered result must match (same rule as
|
||||
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
|
||||
event definitions before declaring a phase done.
|
||||
|
||||
### Frontend after
|
||||
|
||||
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
|
||||
- `item.type === "Audio"` → `item.kind === "track"` (127 sites, mechanical).
|
||||
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
|
||||
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
|
||||
they exist, which are not Jellyfin-specific).
|
||||
- `primaryImageTag` → `imageId` through the existing image-URL command.
|
||||
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
|
||||
- Assert with the boundary tripwire + a new grep (see acceptance).
|
||||
|
||||
## Phased migration
|
||||
|
||||
This is too large and too collision-prone for one change. Phases are independently
|
||||
shippable, each keeps all tests green, and each is a reviewable PR:
|
||||
|
||||
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
|
||||
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
|
||||
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
|
||||
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
|
||||
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
|
||||
wire shape is a superset of today's, so the frontend still compiles and
|
||||
behaves identically. Lands the authority + full mapping unit coverage first,
|
||||
with zero blast radius on the 52 construction sites (they set the old fields;
|
||||
new ones default).
|
||||
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
|
||||
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
|
||||
mechanically (`type`→`kind`, values `"Audio"`→`"track"`, `runTimeTicks`→
|
||||
`durationMs`, `primaryImageTag`→`imageId`). This is the big mechanical PR;
|
||||
`bun run check` is the driver.
|
||||
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
|
||||
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
|
||||
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
|
||||
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
|
||||
outside tests.
|
||||
|
||||
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
|
||||
- Changing any user-visible behaviour, layout, or copy.
|
||||
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
|
||||
they carry the fields being renamed — align them in phase 2 only if the compiler
|
||||
demands it.
|
||||
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
|
||||
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
|
||||
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
|
||||
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
|
||||
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
|
||||
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
|
||||
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
|
||||
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
|
||||
item type (Rust test with a garbage type string).
|
||||
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
|
||||
same navigation, offline included.
|
||||
- [ ] Both command returns and event payloads carry the new shape (no flicker).
|
||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
|
||||
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
|
||||
critical surface —
|
||||
- every known `item_type` → correct `MediaKind` (table test over all 20 values
|
||||
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
|
||||
- unknown type string → safe fallback, no panic;
|
||||
- `run_time_ticks` → `duration_ms` (10_000 divisor), boundary/None cases;
|
||||
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
|
||||
|
||||
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
|
||||
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
|
||||
deleted with their modules. Add a compose/render test proving `kind`-based
|
||||
branching matches the old `type`-based branching for a representative mix.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
|
||||
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
|
||||
phases share the DRs of the capability each touches (don't invent per-file DRs).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **This is the highest-collision change in the repo's history** — it touches 36+
|
||||
frontend files and the core Rust types. A parallel Claude session in any media
|
||||
file will conflict. Strongly prefer a dedicated worktree per phase, and
|
||||
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
|
||||
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
|
||||
the disruptive flip. Resist the urge to skip it.
|
||||
- IPC camelCase rules apply to the new enums/structs
|
||||
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
|
||||
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
|
||||
`bindings.ts`, never hand-edit.
|
||||
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
|
||||
Layer assignment table above is the load-bearing section.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Spec: Offline "downloaded only" filtering (issue #10)
|
||||
|
||||
**Status:** Implemented
|
||||
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
|
||||
commands, no schema changes, no UI additions.
|
||||
**Requirements:** UR-052 → DR-078, DR-079, DR-080
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
|
||||
media does not work."*
|
||||
|
||||
## Summary
|
||||
|
||||
Offline, a library page is supposed to show **only media on the device**, with a
|
||||
"Show all server media" toggle that additionally reveals the cached server
|
||||
catalog greyed out (queueable for download on reconnect). In practice the toggle
|
||||
does not gate the listing — every server item still appears. This spec fixes
|
||||
that with two independent changes; either one alone leaves the bug visible.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code. **The feature is built and mostly correct — this is a
|
||||
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
|
||||
command, or the SQL gate.
|
||||
|
||||
1. **The SQL gate works and is unit-tested.**
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
|
||||
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
|
||||
true; with it false, only downloaded/local rows return. Guarded by
|
||||
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
|
||||
query.**
|
||||
|
||||
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
|
||||
`set_show_server_catalog` command
|
||||
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
|
||||
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
|
||||
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
|
||||
`include = connected || showCatalog` and pushes it on every change.
|
||||
|
||||
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
|
||||
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
|
||||
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
|
||||
them.
|
||||
|
||||
4. **`MediaCard` already greys and queues.**
|
||||
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
|
||||
`isServerOnly` renders the greyed, inert card with a queue button; the queued
|
||||
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
|
||||
it.
|
||||
|
||||
## The two defects
|
||||
|
||||
### Defect A — offline is never actually entered (DR-079)
|
||||
|
||||
`pushCatalogVisibility` keys off `isConnected`, but
|
||||
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
|
||||
|
||||
```ts
|
||||
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
|
||||
```
|
||||
|
||||
`navigator.onLine` is documented in that same file as **advisory only** — the
|
||||
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
|
||||
real traffic*, DR-055). When the server is unreachable but the device link is
|
||||
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
|
||||
stays true, so `include` stays true, so the backend keeps returning the full
|
||||
catalog. The user is "offline" in every meaningful sense but the toggle never
|
||||
gets a chance to gate anything.
|
||||
|
||||
This is the primary cause: it explains why the filter looks dead rather than
|
||||
merely inverted — the gate never closes.
|
||||
|
||||
### Defect B — an intentionally empty result falls through to the server (DR-080)
|
||||
|
||||
With the gate off and nothing downloaded in a library, offline `get_items`
|
||||
correctly returns few or zero rows. But
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
|
||||
hit only `if data.has_content()`. An empty offline result is indistinguishable
|
||||
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
|
||||
~10 other reads) falls through to the server and returns the full server list —
|
||||
re-defeating the filter even after Defect A is fixed.
|
||||
|
||||
## Design
|
||||
|
||||
### Fix A: `isConnected` follows backend reachability alone (DR-079)
|
||||
|
||||
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
|
||||
store:
|
||||
|
||||
```ts
|
||||
export const isConnected = derived(
|
||||
connectivity,
|
||||
($c) => $c.isServerReachable
|
||||
);
|
||||
```
|
||||
|
||||
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
|
||||
immediate recheck (`online`/`offline` listeners already call
|
||||
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
|
||||
Leave `isOnline` on the state object and the listeners intact.
|
||||
|
||||
Consider whether the optimistic `isServerReachable: true` startup default
|
||||
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
|
||||
first real check resolves. Keep it — flipping the app to "offline" on launch is a
|
||||
worse regression than a brief full-catalog flash before the first probe. Note the
|
||||
choice in a comment.
|
||||
|
||||
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
|
||||
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
|
||||
anything importing it). Enumerate consumers first:
|
||||
|
||||
```
|
||||
grep -rn "isConnected" src/ | grep -v node_modules
|
||||
```
|
||||
|
||||
For each, confirm "server unreachable" (not "device link down") is the correct
|
||||
trigger. It almost always is — that is the whole point of the reachability model
|
||||
— but verify rather than assume, and call out anything that genuinely wanted the
|
||||
device link in the PR description.
|
||||
|
||||
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
|
||||
|
||||
The backend must distinguish "cache is cold, go ask the server" from "user asked
|
||||
for downloads only and there are none here." The gate flag already encodes intent
|
||||
— reuse it.
|
||||
|
||||
Add a getter beside the existing setter in
|
||||
[offline.rs](../../src-tauri/src/repository/offline.rs):
|
||||
|
||||
```rust
|
||||
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
|
||||
```
|
||||
|
||||
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
|
||||
`!include_catalog_browse()`, treat the offline result as authoritative and return
|
||||
it **as-is even when empty** — do not spawn/await the server fallback for this
|
||||
call. When the flag is on (online fast-path, or offline with the toggle on),
|
||||
behaviour is unchanged: empty cache still falls through to the server.
|
||||
|
||||
Keep it surgical:
|
||||
|
||||
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
|
||||
thread it into `parallel_race` or the other readers, which have no catalog
|
||||
gate and legitimately want the server on an empty cache.
|
||||
- Preserve the online path exactly: with the flag on (its default, and always so
|
||||
while reachable) the method behaves as it does today, including the background
|
||||
cache refresh on a hit.
|
||||
- The flag is process-global `Relaxed`; it is set from the frontend before the
|
||||
query. That ordering already holds for the SQL gate — no new synchronization.
|
||||
|
||||
### Why both
|
||||
|
||||
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
|
||||
downloads present the list still gets padded by the server fallback whenever a
|
||||
library's cache is thin. B alone: the gate never closes because `isConnected`
|
||||
never goes false on a live link. Ship them together.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
|
||||
- `MediaCard` greying / queue-on-reconnect — correct.
|
||||
- Home-screen and resume queries — already downloads-only.
|
||||
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
|
||||
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
|
||||
- Any new IPC command, DB column, or settings entry.
|
||||
- Making the "Show all server media" toggle reachable from Settings (that is a
|
||||
UX-placement question, tracked separately under UR-051's toggle note).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [~] With the server unreachable on a live device link, a library page lists
|
||||
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
|
||||
coverage via UT-069 + gate tests).
|
||||
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
|
||||
hides it again — without leaving/re-entering the page (SQL gate + toggle
|
||||
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
|
||||
- [x] A library with downloads and a thin cache does not get padded with
|
||||
non-downloaded server items when offline with the toggle off (Defect B —
|
||||
UT-070: gate off + empty offline result returned as-is, server not queried).
|
||||
- [x] `isConnected` is false whenever the server is unreachable, regardless of
|
||||
`navigator.onLine`; true for a reachable server even if the browser reports
|
||||
offline (UT-069).
|
||||
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
|
||||
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
|
||||
"server unreachable", which is the new semantics; `CastButton`'s local
|
||||
`isConnected` is unrelated). Full frontend suite (616 tests) green.
|
||||
- [x] Online behaviour is unchanged: with the flag on (its default, always so
|
||||
while reachable) `get_items` keeps the offline fast-path and background
|
||||
refresh (UT-067 + gate-on fall-through test).
|
||||
- [~] A download queued from a greyed offline card resolves and starts on
|
||||
reconnect (IT-017 — regression check, no code change; offlineCatalog
|
||||
resume path untouched).
|
||||
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
|
||||
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
|
||||
touched files).
|
||||
|
||||
## Testing
|
||||
|
||||
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
|
||||
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
|
||||
|
||||
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
|
||||
result as-is and does **not** query the server. Assert via a mock online repo
|
||||
whose `get_items` bumps a call counter that must stay at zero.
|
||||
- Gate on + empty cache still falls through to the server (guard the online path).
|
||||
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
|
||||
|
||||
Frontend (vitest, `src/lib/**/*.test.ts`):
|
||||
|
||||
- **UT-069** — `isConnected` follows `isServerReachable` alone: false when
|
||||
unreachable with `navigator.onLine === true`; true when reachable with
|
||||
`navigator.onLine === false`.
|
||||
- **UT-068** — `pushCatalogVisibility` resolves `serverReachable || showCatalog`
|
||||
and pushes to the backend on a change of either input (extend the existing
|
||||
offlineCatalog tests).
|
||||
|
||||
Integration (IT-016, IT-017) are documented as pending in
|
||||
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
|
||||
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
|
||||
|
||||
New/changed requirement code keeps its `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
|
||||
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
|
||||
(`… | DR-078`). Update the getter's tag when you expose it.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
|
||||
before Fix A — it is the canonical statement of the reachability model this fix
|
||||
restores fidelity to.
|
||||
- Fix B relies on the frontend having pushed the flag before the query runs; that
|
||||
ordering already holds for the SQL gate today. No new locking.
|
||||
- Another session is active in this repo (WiFi-only downloads, account menu
|
||||
landed alongside this work). Check `git diff` before "repairing" unexpected
|
||||
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
|
||||
other new rows.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Spec: Move search scope taxonomy behind the Rust boundary
|
||||
|
||||
**Status:** Proposed
|
||||
**Scope:** Rust + Frontend. **Revises a decision in
|
||||
[scoped-search.md](scoped-search.md).**
|
||||
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
|
||||
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
|
||||
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
|
||||
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
|
||||
architecture/boundary change with **no user-visible behaviour difference**.
|
||||
|
||||
## Why this spec exists
|
||||
|
||||
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
|
||||
Rust changes." That was the smallest wiring change, and it worked — but it left
|
||||
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
|
||||
violates the project's core boundary rule ("Svelte frontend — presentation
|
||||
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
|
||||
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
|
||||
|
||||
The offending knowledge lives in
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts):
|
||||
|
||||
```ts
|
||||
const SCOPE_ITEM_TYPES = {
|
||||
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
|
||||
movies: ["Movie"],
|
||||
tv: ["Series", "Episode"],
|
||||
};
|
||||
const GROUP_ITEM_TYPES = {
|
||||
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
|
||||
movies: ["Movie"], tvShows: ["Series", "Episode"],
|
||||
};
|
||||
```
|
||||
|
||||
This is a **domain definition** — "what the category *Music* means in Jellyfin's
|
||||
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
|
||||
creates: the day the backend starts returning a type the frontend never
|
||||
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
|
||||
drops it from both the query filter and the result buckets, and nothing in the
|
||||
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
|
||||
sources of truth that will drift.
|
||||
|
||||
**This must be fixed while the feature is uncommitted**, before the leak ships
|
||||
baked into a released wire contract.
|
||||
|
||||
### What is *not* a leak (leave it alone)
|
||||
|
||||
Single concrete-type list pages are **not** business logic and stay as-is:
|
||||
|
||||
- `music.ts` → `["MusicAlbum"]` / `["Playlist"]`, `movies.ts` → `["Movie"]`,
|
||||
`tv.ts` → `["Series"]`
|
||||
- `GenericMediaListPage.svelte` → `[config.itemType]`
|
||||
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
|
||||
`PersonDetailView`
|
||||
|
||||
"This page shows albums" is a legitimate presentation choice expressed through a
|
||||
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
|
||||
taxonomy** (a semantic category → many types, defined once and reused) crosses
|
||||
the line. Do **not** invent a backend enum for every list page — that is
|
||||
over-abstraction, not cleaner separation.
|
||||
|
||||
## The boundary rule after this change
|
||||
|
||||
> The frontend never names a Jellyfin item type **in connection with search.**
|
||||
> It sends an opaque `scope`, and receives results already sorted into labelled
|
||||
> groups. The frontend owns only **group order** (presentation) and
|
||||
> **rendering**.
|
||||
|
||||
## Design
|
||||
|
||||
### Rust owns scope → item-types (query side)
|
||||
|
||||
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
|
||||
|
||||
```rust
|
||||
// repository/types.rs
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchScope { All, Music, Movies, Tv }
|
||||
|
||||
impl SearchScope {
|
||||
/// The Jellyfin item types this scope requests, or None for `All`
|
||||
/// (which must send NO includeItemTypes — see below).
|
||||
pub fn item_types(self) -> Option<Vec<String>> {
|
||||
match self {
|
||||
SearchScope::All => None,
|
||||
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
|
||||
.into_iter().map(String::from).collect()),
|
||||
SearchScope::Movies => Some(vec!["Movie".into()]),
|
||||
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SearchOptions` gains `scope` and the search command resolves it into the
|
||||
existing `include_item_types` filter **inside Rust**, before dispatching to the
|
||||
online/offline paths (which already honour `include_item_types` — do not touch
|
||||
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
|
||||
|
||||
```rust
|
||||
pub struct SearchOptions {
|
||||
pub limit: Option<usize>,
|
||||
pub search_term: Option<String>,
|
||||
pub scope: Option<SearchScope>, // NEW
|
||||
// include_item_types stays for the single-type list-page callers,
|
||||
// but the SEARCH command derives it from `scope` when scope is set.
|
||||
}
|
||||
```
|
||||
|
||||
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
|
||||
non-search `getItems` callers. Document this so a future reader does not send
|
||||
both.
|
||||
|
||||
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
|
||||
`includeItemTypes` entirely, not send the union of every enumerated type — types
|
||||
nobody listed (Person, folders) would otherwise be filtered out. This is why
|
||||
`item_types()` returns `Option`, and the command must skip the filter on `None`.
|
||||
|
||||
### Rust owns result bucketing (result side)
|
||||
|
||||
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
|
||||
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
|
||||
|
||||
```rust
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
|
||||
|
||||
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
|
||||
```
|
||||
|
||||
Rust emits **every** non-empty group it can classify, in a stable canonical
|
||||
order. It does **not** apply the user's ordering or drop out-of-scope groups —
|
||||
those are presentation and stay frontend-side (see below). Items whose type maps
|
||||
to no group are omitted from grouped output (same as today's frontend filter).
|
||||
|
||||
### 🔴 The `search-event` wrinkle — both payloads must change
|
||||
|
||||
Search returns results **twice**: the command resolves with instant local-cache
|
||||
results, then the merged cache+server union arrives later via the `search-event`
|
||||
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
|
||||
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
|
||||
command return value **and** the `search-event` payload must carry
|
||||
`GroupedSearchResult`. If only one is converted, the instant results group and
|
||||
the merged ones do not (or vice versa), and the UI flickers between shapes. This
|
||||
is the single largest part of the change and the easiest to half-do.
|
||||
|
||||
### What the frontend keeps (all pure presentation)
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
|
||||
|
||||
- `SearchScope` type — now sourced from the generated bindings, mirroring the
|
||||
Rust enum (delete the hand-written union).
|
||||
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
|
||||
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
|
||||
unit-tested. **Stays exactly as-is.**
|
||||
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
|
||||
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
|
||||
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
|
||||
presentation.
|
||||
|
||||
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
|
||||
|
||||
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
|
||||
- `scopeItemTypes()`, `groupItemTypes()`.
|
||||
- The `.type`-inspecting body of `composeSearchGroups()`.
|
||||
|
||||
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
|
||||
groups** — no `.type` inspection anywhere:
|
||||
|
||||
```ts
|
||||
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
|
||||
// attach labels, omit empties. No Jellyfin type vocabulary.
|
||||
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
|
||||
```
|
||||
|
||||
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
|
||||
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
|
||||
filtered the query by scope, out-of-scope groups will simply be **empty** and
|
||||
drop out via the empty-omit rule — so the frontend does not strictly need
|
||||
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
|
||||
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
|
||||
it as a display hint, not authority.
|
||||
|
||||
### Frontend call-site changes
|
||||
|
||||
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
|
||||
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
|
||||
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
|
||||
event merge) is preserved.
|
||||
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
|
||||
client-side `composeSearchGroups(results, …)`. The store now holds grouped
|
||||
results.
|
||||
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
|
||||
behaviour; only the type it passes to `SearchResults` changes.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any change to online/offline `include_item_types` **filtering** — it already
|
||||
works; only the *source* of the type list moves.
|
||||
- Single concrete-type list pages (see "What is not a leak").
|
||||
- Ranking within or across groups.
|
||||
- The UX / chip behaviour / persistence mechanism — all unchanged from
|
||||
[scoped-search.md](scoped-search.md).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
|
||||
in `searchScope.ts` or any search call path. Verify:
|
||||
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
|
||||
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
|
||||
`bindings.ts`, not hand-written unions.
|
||||
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
|
||||
groups, same order, same empty/out-of-scope omission, offline included.
|
||||
- [ ] Both the command return and the `search-event` payload carry the grouped
|
||||
shape; no shape flicker between instant and merged results.
|
||||
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
|
||||
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
|
||||
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
|
||||
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
|
||||
committed.
|
||||
|
||||
## Testing
|
||||
|
||||
**Rust** (`src-tauri`, `cargo test`):
|
||||
- `SearchScope::item_types()`: each scope's list, and `All` → `None`.
|
||||
- Search command: `scope: Music` resolves to the four music types on the query;
|
||||
`scope: All` sends no `include_item_types`.
|
||||
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
|
||||
unknown types are dropped; groups come out in canonical order.
|
||||
- The `search-event` payload is the grouped shape (guard the wrinkle).
|
||||
|
||||
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
|
||||
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
|
||||
outgoing options — **rewrite** to assert `scope` is sent instead.
|
||||
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
|
||||
extend `resolveSearchScope`, order normalize/move/reorder, and the new
|
||||
compose-over-groups (order + empty-omit, no type inspection).
|
||||
- `searchGroupOrder.test.ts` — unchanged.
|
||||
|
||||
## TRACES
|
||||
|
||||
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
|
||||
- `SearchScope` enum + `item_types()` + search command scope resolution:
|
||||
`UR-049 | DR-063` (revised — resolution now Rust-side).
|
||||
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
|
||||
the wire shape.
|
||||
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
|
||||
§Design "Scope model / Threading scope through the store," which asserted no
|
||||
Rust change. Update that spec's status to note the boundary was moved, or add a
|
||||
banner pointing here — do not leave the two specs contradicting silently.
|
||||
- The IPC camelCase rule applies to the new enums and structs
|
||||
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
|
||||
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
|
||||
`tauriIntegration`-style test if a new command is introduced.
|
||||
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
|
||||
types; do not hand-edit it.
|
||||
- **Another Claude session may be active in these same files** (per project
|
||||
memory). `git diff` before repairing anything unexpected; these search files
|
||||
are exactly the ones a parallel session touched.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Spec: Context-scoped search with filter chips and configurable group order
|
||||
|
||||
> ⚠️ **Superseded in part by
|
||||
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
|
||||
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
|
||||
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
|
||||
> presentation layer, which violates the backend/frontend boundary. The taxonomy
|
||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
||||
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||
> changes. Read the boundary spec before touching search code.
|
||||
|
||||
**Status:** Implemented (boundary revision pending — see banner above)
|
||||
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
|
||||
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||
(see [requirements.md](../requirements.md)).
|
||||
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
|
||||
§6.3 group order, §6.4 current deviations.
|
||||
|
||||
## Summary
|
||||
|
||||
Two related changes to search:
|
||||
|
||||
1. **Scope** — a search started inside a library searches *that* library.
|
||||
Started from Home, `/library`, or the search tab, it searches everything.
|
||||
The active scope shows as a chip row under the search bar, preselected from
|
||||
context and freely changeable without retyping.
|
||||
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
|
||||
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
|
||||
|
||||
## Motivation
|
||||
|
||||
Searching "office" while browsing TV currently returns music albums, because
|
||||
both search entry points call the same unscoped query. The user has already
|
||||
told us what they're looking at; ignoring that makes search feel indiscriminate
|
||||
and pushes the relevant result below unrelated media.
|
||||
|
||||
## Background: what already exists
|
||||
|
||||
Verified in code — **most of the plumbing is already there.** This is
|
||||
substantially a wiring task, not new infrastructure.
|
||||
|
||||
1. **`SearchOptions` already carries the filter.**
|
||||
[bindings.ts](../../src/lib/api/bindings.ts) —
|
||||
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
|
||||
|
||||
2. **Rust already honours `include_item_types` on both paths** — online
|
||||
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
|
||||
options mapping) and offline
|
||||
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
|
||||
type filter from it). **Do not add Rust code for filtering.**
|
||||
|
||||
3. **Per-page list search already does this correctly.**
|
||||
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
|
||||
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
|
||||
the reference for the call shape, including the `requestId` handling.
|
||||
|
||||
4. **The gap is exactly one function.**
|
||||
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
|
||||
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
|
||||
any scope. Both callers
|
||||
([search/+page.svelte](../../src/routes/search/+page.svelte) and
|
||||
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
|
||||
it.
|
||||
|
||||
5. **Group order is hardcoded in markup.**
|
||||
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
|
||||
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
|
||||
renders three fixed sections in source order.
|
||||
|
||||
6. **Frontend preferences persist via `localStorage`**, per the existing
|
||||
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
|
||||
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
|
||||
command for this.
|
||||
|
||||
## Design
|
||||
|
||||
### Scope model
|
||||
|
||||
One `SearchScope` type, defined once and shared:
|
||||
|
||||
| Scope | `includeItemTypes` | Chip label |
|
||||
|-------|--------------------|------------|
|
||||
| `all` | *unset* | All |
|
||||
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
|
||||
| `movies` | `Movie` | Movies |
|
||||
| `tv` | `Series`, `Episode` | TV |
|
||||
|
||||
`all` must send **no** `includeItemTypes` key rather than a list of every type —
|
||||
the two are not equivalent for item types not enumerated here (Person, folders).
|
||||
|
||||
### Route → scope resolution (DR-063)
|
||||
|
||||
A pure function, unit-testable without a DOM:
|
||||
|
||||
```ts
|
||||
resolveSearchScope(pathname: string): SearchScope
|
||||
```
|
||||
|
||||
- `/library/music*` → `music`
|
||||
- `/library/movies*` → `movies`
|
||||
- `/library/tv*` → `tv`
|
||||
- `/`, `/library`, `/search`, anything else → `all`
|
||||
|
||||
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
|
||||
current route list before finalising — do not assume this table is exhaustive.
|
||||
|
||||
### Scope is a starting point, not a lock (DR-064)
|
||||
|
||||
The resolved scope sets the **initial** chip only. Once the user taps a chip,
|
||||
their choice governs until they leave the search surface. Concretely: derive the
|
||||
initial value from the route, hold it in component state, and do not re-derive
|
||||
it on every navigation — otherwise a user who widens to All snaps back to TV.
|
||||
|
||||
Changing a chip re-runs the current query at the new scope. Changing the query
|
||||
keeps the current scope.
|
||||
|
||||
### Threading scope through the store (DR-065)
|
||||
|
||||
Extend the store's search signature to accept an optional scope and pass
|
||||
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
|
||||
exactly: the `requestId` bump, the stale-response guard, the `search-event`
|
||||
listener merge, the 10s timeout, and the empty-query clear path. This is an
|
||||
additive parameter — no caller should break.
|
||||
|
||||
### Group order (DR-066, DR-067)
|
||||
|
||||
Persist an ordered array of group ids:
|
||||
|
||||
```
|
||||
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
|
||||
```
|
||||
|
||||
Rendering composes scope and order as **two independent axes**, in this order:
|
||||
|
||||
1. drop groups outside the active scope,
|
||||
2. sort the remainder by the user's saved order,
|
||||
3. omit groups that came back empty.
|
||||
|
||||
Scope never rewrites the saved order — narrowing to Music and back to All must
|
||||
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
|
||||
the worked example.
|
||||
|
||||
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
|
||||
keyboard-operable move up/down controls with proper labels, or the setting is
|
||||
unusable with a screen reader and on any pointerless input.
|
||||
|
||||
Unknown or missing ids in the stored array must not crash rendering — treat the
|
||||
stored order as a hint, append any group it doesn't mention, and ignore ids that
|
||||
no longer exist. A user upgrading from a build with fewer groups must not lose
|
||||
the new ones.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Ranking *within* a group. Order is presentation-only.
|
||||
- Server-side search ranking or the Jellyfin query itself.
|
||||
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
|
||||
is already implicitly scoped by its own `itemType`.
|
||||
- Any Rust change.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
|
||||
- [ ] Searching from Home, `/library`, or the search tab returns all types.
|
||||
- [ ] The chip row renders under the search bar on both the search page and the
|
||||
in-library header search, with the context-derived chip preselected.
|
||||
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
|
||||
query preserves the selected chip.
|
||||
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
|
||||
- [ ] Result groups render in the user's configured order, with out-of-scope and
|
||||
empty groups omitted and relative order preserved.
|
||||
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
|
||||
restarts, and ships with the documented default.
|
||||
- [ ] Offline search respects scope (the offline path already filters — verify,
|
||||
don't reimplement).
|
||||
- [ ] `bun run check` and `bun run test` pass.
|
||||
|
||||
## Testing
|
||||
|
||||
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
|
||||
|
||||
- `resolveSearchScope` — pure unit tests over the route table, including the
|
||||
`/library/shows/genres` case and unknown routes falling back to `all`.
|
||||
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
|
||||
- The compose step: scope filter + user order + empty-group omission, including
|
||||
the "narrow then widen restores order" case and a stored order containing an
|
||||
unknown id.
|
||||
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
|
||||
the existing stale-`requestId` guard still discards superseded responses.
|
||||
|
||||
New requirement-implementing code needs `TRACES:` comments — see
|
||||
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
|
||||
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
|
||||
and ordered rendering `UR-050 | DR-066, DR-067`.
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
|
||||
document is the implementation plan.
|
||||
- The IPC camelCase rule applies to anything new that crosses the boundary
|
||||
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
|
||||
- Another session may be active in this repo. Check `git diff` before
|
||||
"repairing" unexpected changes.
|
||||
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
|
||||
|
||||
## Gitea Actions Workflows
|
||||
|
||||
Two workflows are configured in `.gitea/workflows/`:
|
||||
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
|
||||
|
||||
### 1. `traceability-check.yml` (Primary - Recommended)
|
||||
Gitea-native workflow with:
|
||||
- ✅ Automatic trace extraction
|
||||
- ✅ Coverage validation against minimum threshold (50%)
|
||||
- ✅ Modified file checking
|
||||
- ✅ Artifact preservation
|
||||
- ✅ Summary reports
|
||||
|
||||
**Runs on:** Every push and pull request
|
||||
**Runs on:** Every push and pull request to `master`/`main`/`develop`
|
||||
|
||||
### 2. `traceability.yml` (Alternative)
|
||||
GitHub-compatible workflow with additional features:
|
||||
- Pull request comments with coverage stats
|
||||
- GitHub-specific integrations
|
||||
A second workflow, `traceability.yml`, previously duplicated this one as a
|
||||
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
|
||||
its only unique step (PR comments via `actions/github-script`) depended on the
|
||||
GitHub REST client, which Gitea does not provide. To add PR comments, post to
|
||||
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
|
||||
`traceability-check.yml` rather than reviving the old file.
|
||||
|
||||
## What Gets Validated
|
||||
|
||||
|
||||
+1935
-585
File diff suppressed because it is too large
Load Diff
+580
-89
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
|
||||
- Logo (links to `/library`)
|
||||
- Navigation links: Home, Library, Downloads, Settings
|
||||
- Search bar (inline)
|
||||
- User menu: Username, Downloads icon, Logout button
|
||||
- Account menu (see §1.2)
|
||||
|
||||
**Mobile Navigation:**
|
||||
|
||||
On mobile, the header contains:
|
||||
- Logo
|
||||
- Three-dot overflow menu button (Android-style)
|
||||
- Overflow menu includes:
|
||||
- Downloads
|
||||
- Settings
|
||||
- Sign out
|
||||
- Account menu button (see §1.2)
|
||||
|
||||
### 1.2 Account Menu
|
||||
|
||||
Account-level destinations — the ones that are *about the user* rather than
|
||||
about media — live behind a single **account menu**, anchored to the user's
|
||||
name/avatar at the right of the header.
|
||||
|
||||
**Contents, in order:**
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Signed in as <name> │ ← identity, not a menu item
|
||||
│ <server host> │
|
||||
├──────────────────────────┤
|
||||
│ ⬇ Downloads │
|
||||
│ ⚙ Settings │
|
||||
│ ▦ Display │ ← grid/list preference (§5A.2)
|
||||
├──────────────────────────┤
|
||||
│ ⇥ Sign out │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **One menu, both platforms.** Desktop and mobile show the same items in the
|
||||
same order. A user who learns where Settings lives on one form factor finds
|
||||
it in the same place on the other.
|
||||
- **Anchored to identity.** The trigger is the username/avatar, because that is
|
||||
where users look for account actions. A bare three-dot icon does not signal
|
||||
"your account".
|
||||
- **Sign out is separated** by a divider and placed last — it is destructive and
|
||||
must not sit adjacent to routine navigation.
|
||||
- **The menu is reachable from every authenticated screen**, not only from
|
||||
library routes. See §1.3.
|
||||
|
||||
**Access Points Summary:**
|
||||
- **Downloads** → Desktop: nav link + icon; Mobile: overflow menu
|
||||
- **Settings** → Desktop: nav link; Mobile: overflow menu
|
||||
- **Downloads** → header icon (desktop) + account menu (both)
|
||||
- **Settings** → header nav link (desktop) + account menu (both)
|
||||
- **Sign out** → account menu only
|
||||
|
||||
### 1.3 Chrome availability
|
||||
|
||||
The header is shared across chrome-bearing routes. Routes fall into three groups:
|
||||
|
||||
| Route group | Header | Bottom nav | Account menu reachable? |
|
||||
|-------------|--------|------------|-------------------------|
|
||||
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
|
||||
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
|
||||
| `/settings` | Own layout | No | n/a — already there |
|
||||
| `/player/*`, `/login` | No | No | No (by design) |
|
||||
|
||||
The rule the app honours: every authenticated, non-immersive screen exposes the
|
||||
account menu. Only the full-screen player and the login screen are chrome-free.
|
||||
|
||||
### 1.4 Known deviations
|
||||
|
||||
*(None — the account-menu and chrome-availability defects tracked here under
|
||||
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
|
||||
from every authenticated non-immersive screen via the shared `AccountMenu`, the
|
||||
username/avatar is the menu trigger, desktop and mobile share one menu, and the
|
||||
Display preference has a Settings entry — UR-029, §5A.4.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -386,9 +439,9 @@ flowchart TB
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
|
||||
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
|
||||
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
|
||||
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
|
||||
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
|
||||
|
||||
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
|
||||
@@ -445,54 +498,387 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
## 5A. Library Page Layouts
|
||||
|
||||
### 6.1 Search Page Navigation
|
||||
Every browse page is one of two shapes: a **card grid** or a **row list**. This
|
||||
section is the rule for which shape a page takes, what a card looks like, and
|
||||
what the user is allowed to change.
|
||||
|
||||
### 5A.1 Card shape follows the media, not the page
|
||||
|
||||
Card aspect ratio is a property of *what the item is*, and is never overridden
|
||||
per-page. This is the single most important layout rule: a user scanning a grid
|
||||
recognises content type by silhouette before reading a word.
|
||||
|
||||
| Item type | Aspect | Rationale |
|
||||
|-----------|--------|-----------|
|
||||
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
|
||||
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
|
||||
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
|
||||
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
|
||||
|
||||
Artist cards are square but rendered **circular-masked**, so artists are
|
||||
distinguishable from albums at a glance within the same music grid.
|
||||
|
||||
### 5A.2 Grid vs. list
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
|
||||
Page[Library browse page] --> Kind{Content kind}
|
||||
|
||||
ClickSearch --> SearchPage[Search Page<br/>/search]
|
||||
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
|
||||
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
|
||||
|
||||
SearchPage --> EmptyState{Has Query?}
|
||||
|
||||
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
|
||||
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
|
||||
|
||||
ShowPrompt --> UserTypes[User Types in Search]
|
||||
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
|
||||
LiveSearch --> ShowResults
|
||||
|
||||
ShowResults --> UserClick{User Clicks Result}
|
||||
|
||||
UserClick -->|Song| PlaySong[Play Song + Queue Results]
|
||||
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
|
||||
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
|
||||
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
|
||||
Grid --> Toggle[View toggle in page header]
|
||||
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
|
||||
```
|
||||
|
||||
**Search Page Layout:**
|
||||
- **Grids are the default** for anything with cover art worth scanning.
|
||||
- **Lists are mandatory, not optional**, where position carries meaning —
|
||||
a track's number within an album, an episode's number within a season.
|
||||
A grid destroys that ordering cue, so these pages expose **no toggle**.
|
||||
- **The toggle is global, not per-page.** A user who prefers dense lists
|
||||
prefers them everywhere; making them re-set it on each page is friction.
|
||||
The choice persists across launches.
|
||||
|
||||
**Responsive columns** (grid mode), tuned so cards stay large enough to read
|
||||
cover art on a phone and don't become postage stamps on a desktop:
|
||||
|
||||
| Breakpoint | Columns |
|
||||
|------------|---------|
|
||||
| base (phone) | 2 |
|
||||
| sm | 3 |
|
||||
| md | 4 |
|
||||
| lg | 5 |
|
||||
| xl | 6 |
|
||||
|
||||
### 5A.3 What a card shows
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ │ ← cover art (aspect per §5A.1)
|
||||
│ artwork │ • progress bar overlay if partially played
|
||||
│ │ • watched/played check if complete
|
||||
│ [▶] │ • play affordance on hover/focus
|
||||
└─────────────┘
|
||||
Primary line ← title, truncated to one line
|
||||
Secondary line ← artist / year+rating / SxEy — one line, dimmed
|
||||
```
|
||||
|
||||
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
|
||||
grows to fit its title breaks grid alignment and makes scanning harder.
|
||||
- **Progress and watched state live on the artwork**, not in the text — they
|
||||
must be readable while scanning, without reading.
|
||||
- **Hover/focus reveals play**, so a card is both a navigation target and a
|
||||
playback target without a second control competing for space at rest.
|
||||
|
||||
### 5A.4 Known deviations
|
||||
|
||||
These are places the implementation currently diverges from the rules above.
|
||||
They are recorded here so the gap is explicit rather than mistaken for intent.
|
||||
|
||||
- **The view toggle is discoverable only on a browse page.** The preference is
|
||||
already global and persisted, but the only control that sets it is the pair
|
||||
of icon buttons in a library page header. Settings has no display section, so
|
||||
there is nowhere to look for it. *(UR-029)*
|
||||
|
||||
---
|
||||
|
||||
## 5B. Video Detail Page Composition
|
||||
|
||||
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
|
||||
surface renders is decided by item type plus the `?episode=` query param, and
|
||||
**section order is part of the spec** — it is what makes "keep watching this
|
||||
show" the path of least resistance.
|
||||
|
||||
### 5B.1 Which surface renders
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Nav[Navigate to /library/[id]] --> Type{Item type}
|
||||
|
||||
Type -->|Person| Person[PersonDetailView]
|
||||
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
|
||||
Type -->|Series| Ep{?episode= param<br/>present?}
|
||||
|
||||
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
|
||||
Ep -->|No| Series[Series detail<br/>§5B.4]
|
||||
|
||||
Focus -->|Back to series| Series
|
||||
Series -->|Click episode| Focus
|
||||
```
|
||||
|
||||
An episode is **never** browsed as a bare `Episode` item page. Clicking an
|
||||
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
|
||||
navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always
|
||||
shown in the context of its series and the series' full episode list is already
|
||||
loaded. Should an episode ever arrive without a `seriesId` (deep link, stale
|
||||
cache), the bare Episode page renders as a fallback and links back to its parent
|
||||
series and season by title so the user is never stranded.
|
||||
|
||||
### 5B.2 Episode Focus View — section order
|
||||
|
||||
**The next episodes appear directly below the current episode, above cast and
|
||||
similar shows.** Nothing may be inserted between the episode hero and the
|
||||
episode strip.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ [←] │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ episode backdrop │ │
|
||||
│ │ Series Name │ │ ← 1. HERO
|
||||
│ │ Episode Title │ │
|
||||
│ │ S2E4 • 48m • ★8.1 │ │
|
||||
│ │ Overview… │ │
|
||||
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
|
||||
│ │ [▶ Play] │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ More Episodes │ ← 2. EPISODE STRIP
|
||||
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
|
||||
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
|
||||
│ │ ││NOW ││ ││ │ │
|
||||
│ └──────┘└──────┘└──────┘└──────┘ │
|
||||
│ │
|
||||
│ Cast │ ← 3. CAST
|
||||
│ ( ○ )( ○ )( ○ )( ○ ) │
|
||||
│ │
|
||||
│ More Like This │ ← 4. SIMILAR
|
||||
│ ┌────┐┌────┐┌────┐┌────┐ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules for the episode strip:**
|
||||
|
||||
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
|
||||
between the current episode and every other section; cast and related
|
||||
content are *below* it, never above.
|
||||
- **Window, not full list.** The strip shows a window around the current
|
||||
episode — roughly 3 before and 6 after — so the immediate next episodes are
|
||||
visible without scrolling, and earlier ones remain reachable by scrolling
|
||||
left. It is horizontally scrollable, not a wrapped grid.
|
||||
- **Forward bias.** More episodes are shown *after* the current one than
|
||||
before it: the dominant intent on this screen is "watch the next one."
|
||||
- **The current episode is present and marked.** It renders in-strip with a
|
||||
"NOW" badge and a highlight ring, and is not clickable. It anchors the
|
||||
user's position in the season rather than being hidden.
|
||||
- **Cross-season continuity.** The window spans the whole series in episode
|
||||
order, so the strip runs past a season boundary into the next season's first
|
||||
episodes rather than dead-ending at the end of a season.
|
||||
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
|
||||
progress bar when partially watched, and a watched checkmark when complete.
|
||||
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
|
||||
not start playback. Playback starts only from the hero's Play button.
|
||||
|
||||
### 5B.3 Movie detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download / Favorite)
|
||||
→ Crew links (Directed by / Written by / Music by)
|
||||
→ Genre tags
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
A movie has no continuation set, so cast follows the hero directly.
|
||||
|
||||
### 5B.4 Series detail — section order
|
||||
|
||||
```
|
||||
Hero (poster, title, metadata, Play / Download)
|
||||
→ Crew links
|
||||
→ Genre tags
|
||||
→ Seasons + episodes (per-season sections)
|
||||
→ Cast
|
||||
→ More Like This
|
||||
```
|
||||
|
||||
The same principle as §5B.2: **episodes come before cast and similar shows.**
|
||||
The reason a user opens a series page is to pick an episode; discovery content
|
||||
is secondary and sits underneath.
|
||||
|
||||
### 5B.5 Home-card interaction — tap opens, long-press plays
|
||||
|
||||
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
|
||||
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
|
||||
item; playback is the deliberate, second gesture.
|
||||
|
||||
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|
||||
|-----------|-------------|---------------------------|
|
||||
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
|
||||
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
|
||||
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
|
||||
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
|
||||
|
||||
Rationale and rules:
|
||||
|
||||
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
|
||||
jumped straight into the player, which made it easy to lose your place in a
|
||||
half-watched item or start a stream you only meant to inspect. Tap now lands
|
||||
on the detail/focus page, where Play is an explicit button.
|
||||
- **Long-press is the shortcut for "just play it."** It surfaces a native
|
||||
confirm (`Play "<name>" now?`) before starting playback, so an accidental
|
||||
hold never blows away a resume position silently.
|
||||
- **The long-press must not fight the carousel.** Detection cancels if the
|
||||
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
|
||||
to scroll never triggers play.
|
||||
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
|
||||
Focus View, never a bare Episode page, so the series context loads.
|
||||
|
||||
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
|
||||
detection) so any surface can opt in; today the Home carousels are the only
|
||||
opt-in. Grids and other surfaces keep tap-to-open with no long-press.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search Flow
|
||||
|
||||
Search is **context-scoped**: what you are looking at when you start a search
|
||||
determines what the search covers. A search begun inside the Music library
|
||||
searches music. A search begun from Home or the top-level library page searches
|
||||
everything. The scope is always shown, and always overridable.
|
||||
|
||||
### 6.1 Scope is inherited from context
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Start[User starts a search] --> Where{Where from?}
|
||||
|
||||
Where -->|Home (/)| All[Scope: All]
|
||||
Where -->|Library root (/library)| All
|
||||
Where -->|Search tab| All
|
||||
Where -->|Inside Music| Music[Scope: Music]
|
||||
Where -->|Inside Movies| Movies[Scope: Movies]
|
||||
Where -->|Inside TV| TV[Scope: TV]
|
||||
|
||||
All --> Chips[Filter chips shown<br/>All chip selected]
|
||||
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
|
||||
Movies --> Chips2
|
||||
TV --> Chips2
|
||||
|
||||
Chips --> Results[Results, grouped by type]
|
||||
Chips2 --> Results
|
||||
|
||||
Results --> Change{User taps a chip}
|
||||
Change --> Rescope[Re-run search at new scope<br/>query preserved]
|
||||
Rescope --> Results
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Context sets the *initial* chip, never a locked filter.** Entering search
|
||||
from TV preselects the TV chip; the user can tap "All" to widen without
|
||||
retyping the query. Scope is a starting point, not a cage.
|
||||
- **Home, `/library`, and the search tab all start at "All".** These are the
|
||||
places a user has expressed no narrower intent.
|
||||
- **Changing scope preserves the query** and re-runs the search. Changing the
|
||||
query preserves the scope.
|
||||
- **Scope maps to item types**, resolved at the point of search:
|
||||
|
||||
| Chip | `includeItemTypes` |
|
||||
|------|--------------------|
|
||||
| All | *(unset — every type)* |
|
||||
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
|
||||
| Movies | `Movie` |
|
||||
| TV | `Series`, `Episode` |
|
||||
|
||||
- **Chips render under the search bar**, on both the dedicated search page and
|
||||
the in-library header search. They are horizontally scrollable if they
|
||||
overflow, never wrapped onto a second row.
|
||||
|
||||
### 6.2 Search page layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ [🔍 Search...] [✕] │
|
||||
│ │
|
||||
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
|
||||
│ │
|
||||
│ Songs ──────────────────────────── │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ ♪ Song Title - Artist 3:45 │
|
||||
│ ♪ Song Title - Artist 4:12 │
|
||||
│ See all (23) │
|
||||
│ │
|
||||
│ Albums ─────────────────────────── │
|
||||
│ [Album Cover] Album Title │
|
||||
│ [Album Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ [Cover] Album Title │
|
||||
│ See all (8) │
|
||||
│ │
|
||||
│ Artists ────────────────────────── │
|
||||
│ [Photo] Artist Name │
|
||||
│ See all (5) │
|
||||
│ ( Photo ) Artist Name │
|
||||
│ See all (5) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Results stay **grouped by type** even when a scope is selected — a Music
|
||||
search still separates Songs / Albums / Artists.
|
||||
- Each group shows a bounded preview with a **See all (n)** affordance rather
|
||||
than an unbounded list, so no single type can bury the others.
|
||||
- Live search is **debounced** as the user types; a query that becomes empty
|
||||
clears results rather than searching for the empty string.
|
||||
|
||||
### 6.3 Result group order is user-configurable
|
||||
|
||||
Which *kind* of thing a user is usually searching for is personal: a
|
||||
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
|
||||
than guessing, the group order is a setting.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Settings[Settings → Search] --> List[Draggable list of result groups]
|
||||
|
||||
List --> Drag[User drags a group up or down]
|
||||
Drag --> Persist[Order persisted]
|
||||
|
||||
Persist --> Render[Rendering a result set]
|
||||
Scope[Active scope chip §6.1] --> Render
|
||||
|
||||
Render --> Filter[1 - Drop groups outside the active scope]
|
||||
Filter --> Sort[2 - Sort remaining groups by user order]
|
||||
Sort --> Prune[3 - Omit groups with no results]
|
||||
Prune --> Show[Render]
|
||||
```
|
||||
|
||||
**Scope and order compose — they are two independent axes.** The scope chip
|
||||
decides *which* groups are eligible; the settings list decides *what sequence*
|
||||
the eligible ones appear in. Order is preserved as a relative ranking, never
|
||||
renumbered per scope:
|
||||
|
||||
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
|
||||
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
|
||||
groups keep their relative order.
|
||||
- Scope **All** with the same setting renders all five in exactly that order.
|
||||
- **Changing scope never rewrites the saved order.** A user who narrows to
|
||||
Music and back to All sees their original arrangement intact.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Drag and drop to reorder**, in a settings list showing every result group
|
||||
(Songs, Albums, Artists, Movies, TV Shows).
|
||||
- **The order applies to grouped results everywhere** — the search page and
|
||||
the in-library header search alike.
|
||||
- **Order is presentation-only.** It never changes which results are returned
|
||||
or how they are ranked *within* a group, only the sequence groups appear in.
|
||||
- **Empty groups are skipped, not gapped.** A group with no results is omitted
|
||||
entirely; it does not reserve space or leave a stray heading.
|
||||
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
|
||||
so the setting is an adjustment, never a prerequisite.
|
||||
- **Keyboard/accessible reordering must exist** alongside dragging — a
|
||||
drag-only control is unusable with a screen reader or without a pointer.
|
||||
|
||||
### 6.4 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Scope is not implemented.** The in-library header search calls the same
|
||||
unscoped query as the global search page, so searching inside TV returns
|
||||
music. The backend already accepts `includeItemTypes` on both the online and
|
||||
offline paths, and the per-page list search already uses it — only the global
|
||||
path ignores it. *(UR-049)*
|
||||
- **Filter chips do not exist** on either search surface. *(UR-049)*
|
||||
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
|
||||
with no setting. *(UR-050)*
|
||||
|
||||
---
|
||||
|
||||
## 7. Download Flows
|
||||
@@ -532,68 +918,166 @@ States:
|
||||
5. [⏸] Paused - Yellow pause icon
|
||||
```
|
||||
|
||||
### 7.2 Managing Downloads Page
|
||||
### 7.2 Downloads = a browsable offline library, not a flat list
|
||||
|
||||
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
|
||||
*the library, filtered to what's on the device*. A user who has downloaded three
|
||||
seasons of a show and two albums thinks in terms of shows and albums, not
|
||||
seventy-odd individual episode/track transfers. So the primary Downloads surface
|
||||
**reuses the library browse screens**, scoped to downloaded content, and keeps
|
||||
the transfer-progress list as a secondary "Transfers" view for the *act* of
|
||||
downloading.
|
||||
|
||||
This splits one overloaded page into two clear jobs:
|
||||
|
||||
| Surface | Answers | Reuses |
|
||||
|---------|---------|--------|
|
||||
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
|
||||
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
User[User] --> NavChoice{Navigation Path}
|
||||
Nav[Open Downloads] --> Downloads[/downloads]
|
||||
|
||||
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
|
||||
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
|
||||
NavChoice -->|Direct| TypeURL[Type /downloads]
|
||||
Downloads --> View{View}
|
||||
View -->|Downloaded (default)| Browse[Offline library browse]
|
||||
View -->|Transfers| Activity[Transfer activity list]
|
||||
|
||||
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
|
||||
HeaderIcon --> DownloadsPage
|
||||
TypeURL --> DownloadsPage
|
||||
Browse --> Libs[Libraries — only those with<br/>downloaded content]
|
||||
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
|
||||
Grid --> Detail[Detail page<br/>same as online]
|
||||
Detail --> Play[Play from local file]
|
||||
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
|
||||
|
||||
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
|
||||
|
||||
ShowTabs --> ActiveTab{Active Tab}
|
||||
|
||||
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
|
||||
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
|
||||
|
||||
ShowActive --> UserAction1{User Action}
|
||||
UserAction1 -->|Pause| PauseDownload[Pause Download]
|
||||
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
|
||||
|
||||
ShowCompleted --> UserAction2{User Action}
|
||||
UserAction2 -->|Play| PlayOffline[Play from Local File]
|
||||
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
|
||||
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
|
||||
Rows --> Ctl[Pause / Resume / Cancel / Retry]
|
||||
```
|
||||
|
||||
**Navigation to Downloads:**
|
||||
- **Desktop:** Click "Downloads" link in header navigation
|
||||
- **All screen sizes:** Click download icon (⬇) button in header user menu
|
||||
- **Direct:** Navigate to `/downloads` route
|
||||
**Why reuse the library screens (not a bespoke list):**
|
||||
|
||||
- **One mental model.** Browsing offline should feel identical to browsing
|
||||
online — same grids, same card shapes, same detail pages, same play action.
|
||||
The only difference is *what's present*, not *how it looks*.
|
||||
- **It already works in the backend.** The offline repository's `get_items`
|
||||
already returns downloaded items **plus** their containers (an album with any
|
||||
downloaded track, a series/season with any downloaded episode). That is a
|
||||
browsable tree today — see §7.4.
|
||||
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
|
||||
browsable library does not.
|
||||
|
||||
### 7.3 The Downloaded browse surface
|
||||
|
||||
**Downloads Page Layout:**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [←] Downloads │
|
||||
│ │
|
||||
│ [Active (3)] [Completed (12)] │
|
||||
│ │
|
||||
│ ─ Downloading ──────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [████████░░] 80% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ [██░░░░░░░░] 20% │
|
||||
│ [⏸ Pause] [✕ Cancel] │
|
||||
│ │
|
||||
│ ─ Queued ───────────────────────── │
|
||||
│ │
|
||||
│ Album Cover Album Title │
|
||||
│ Artist Name │
|
||||
│ Waiting... │
|
||||
└─────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Downloads │
|
||||
│ ( Downloaded ) ( Transfers ) ← view switch
|
||||
│ │
|
||||
│ [~ 3.4 GB on device · 12 items] Manage ▸ │ ← storage summary
|
||||
│ │
|
||||
│ Music │ ← only libraries that
|
||||
│ ┌────┐┌────┐┌────┐ │ have downloaded content
|
||||
│ │alb ││alb ││art │ │
|
||||
│ └────┘└────┘└────┘ │
|
||||
│ │
|
||||
│ TV │
|
||||
│ ┌────┐┌────┐ │
|
||||
│ │show││show│ │
|
||||
│ └────┘└────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
|
||||
music is downloaded, only Music appears.
|
||||
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
|
||||
browse is the same components with an offline-scoped data source, never a
|
||||
parallel re-implementation.
|
||||
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
|
||||
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
|
||||
- **Disk usage is shown where the user already looks**, in familiar units — see
|
||||
§7.3.1.
|
||||
- **Play always plays the local file** here; nothing on this surface streams.
|
||||
- **Remove is available at every level** — item, album/season, series — and
|
||||
states clearly what it frees. Removing the last downloaded child of a
|
||||
container removes the container from the browse.
|
||||
- **This surface works identically online and offline.** It is "what's on the
|
||||
device," a question whose answer does not depend on connectivity. It must not
|
||||
wait for, or be emptied by, server reachability.
|
||||
|
||||
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
|
||||
|
||||
Users want to know what each thing costs on disk, but that information has to
|
||||
feel like the storage views they already know (phone Settings → Storage, a
|
||||
file browser), not a developer's byte dump.
|
||||
|
||||
- **Size rides along with the item, on the card and the detail page** — a small
|
||||
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
|
||||
report" screen the user has to go find.
|
||||
- **Containers show their total.** A series shows the sum of its downloaded
|
||||
episodes; an album the sum of its tracks; a season its own subtotal. The
|
||||
number a user sees on the "Breaking Bad" card is what removing it frees.
|
||||
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
|
||||
and use it everywhere. Show 2–3 significant figures (`1.2 GB`, not
|
||||
`1,283,048,192 bytes` and not `1.28394 GB`).
|
||||
- **A single device total sits at the top** of the Downloaded surface
|
||||
(`3.4 GB on device · 12 items`) so the headline number is answered before the
|
||||
user scans. It reconciles with the sum of what's listed.
|
||||
- **Remove restates the reclaim** in the same units at the point of action
|
||||
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
|
||||
legible exactly when the user decides.
|
||||
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
|
||||
what to clear) but is not required for v1.
|
||||
|
||||
The bytes-on-disk per item are a backend fact (the download manager writes the
|
||||
files and can stat them); this is a display and aggregation task, not new
|
||||
tracking. See §7.7 deviations for what's missing today.
|
||||
|
||||
### 7.4 Transfers (activity) view
|
||||
|
||||
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
|
||||
It is about *transfers in flight*, so it shows only rows that are doing or
|
||||
waiting to do something:
|
||||
|
||||
- **States:** downloading (with progress), queued, paused, failed,
|
||||
waiting-for-WiFi (§7.5).
|
||||
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
|
||||
and auto-pump are backend concerns and are not surfaced as manual controls.
|
||||
- **Completed transfers fall off this view** once done — the finished item lives
|
||||
in Downloaded, not here. A transient "just finished" confirmation is fine; a
|
||||
permanent completed-list is not (that's what Downloaded is for).
|
||||
- **Empty state** points at the library: "Nothing downloading. Browse your
|
||||
library and tap download to save media for offline."
|
||||
|
||||
### 7.5 Navigation & entry points
|
||||
|
||||
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
|
||||
link/icon → `/downloads`.
|
||||
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
|
||||
and should draw attention (badge/count) only while transfers are active.
|
||||
- Initiating a download is unchanged (§7.1): the download button lives on
|
||||
item/album/series detail pages. The Downloads page manages and browses; it is
|
||||
not where you start a download.
|
||||
|
||||
### 7.7 Known deviations
|
||||
|
||||
Recorded so the gap between this spec and the build is explicit.
|
||||
|
||||
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
|
||||
row per individual transfer with no browsing, grouping, or reuse of the
|
||||
library screens. Completed downloads never collapse into their album/series.
|
||||
*(UR-055)*
|
||||
- **No offline-scoped browse entry point exists in the client.** All browsing
|
||||
goes through the hybrid repository, which merges cache **and** server; there is
|
||||
no way to ask for "downloaded content only" as a browse surface. The offline
|
||||
repository supports it (§7.2) but is not reachable independently. *(UR-055,
|
||||
DR-082)*
|
||||
- **The "on device" storage summary and per-container remove** are absent from
|
||||
the completed list. *(UR-055, UR-056)*
|
||||
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
|
||||
no size; there is no device total, no container subtotal, and Remove does not
|
||||
state what it frees. *(UR-056)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Settings & Account Flows
|
||||
@@ -627,6 +1111,13 @@ flowchart TB
|
||||
- **Mobile:** Click three-dot overflow menu → Select "Settings"
|
||||
- **Direct:** Navigate to `/settings` route
|
||||
|
||||
**Settings apply instantly.** Every control on the Settings page persists the
|
||||
moment the user changes it — toggling a switch, picking a level, or releasing a
|
||||
slider writes that setting immediately. There is **no "Save" button** and no
|
||||
save/dirty state to reason about; leaving the page never risks losing a change.
|
||||
Sliders update their live readout while dragging but only persist on release
|
||||
(`change`, not each `input` tick) to avoid flooding the backend.
|
||||
|
||||
### 8.2 Logout Flow
|
||||
|
||||
```mermaid
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.1",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.5",
|
||||
@@ -17,6 +17,7 @@
|
||||
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
|
||||
"test:all": "./scripts/test-all.sh",
|
||||
"test:rust": "./scripts/test-rust.sh",
|
||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||
"android:build": "./scripts/build-android.sh",
|
||||
"android:build:release": "./scripts/build-android.sh release",
|
||||
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
|
||||
@@ -24,6 +25,12 @@
|
||||
"android:dev": "./scripts/build-and-deploy.sh",
|
||||
"android:check": "./scripts/check-android.sh",
|
||||
"android:logs": "./scripts/logcat.sh",
|
||||
"desktop:build:linux": "./scripts/build-desktop-linux.sh",
|
||||
"desktop:build:arch": "./scripts/build-arch.sh",
|
||||
"desktop:build:windows": "./scripts/build-windows-cross.sh",
|
||||
"docker:build:linux": "docker compose run --rm desktop-linux-build",
|
||||
"docker:build:arch": "docker compose run --rm arch-build",
|
||||
"docker:build:windows": "docker compose run --rm windows-cross",
|
||||
"clean": "./scripts/clean.sh",
|
||||
"tauri": "tauri",
|
||||
"traces": "bun run scripts/extract-traces.ts",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
|
||||
#
|
||||
# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
|
||||
#
|
||||
# This PKGBUILD builds from the local source tree by default (see the `dev`
|
||||
# convenience below), which is what scripts/build-arch.sh uses inside the Arch
|
||||
# Docker stage. For AUR distribution, replace the `source=()` line with a release
|
||||
# tarball/VCS URL and drop the local-copy prepare() step.
|
||||
|
||||
pkgname=jellytau
|
||||
pkgver=0.0.18
|
||||
pkgrel=1
|
||||
pkgdesc="A cross-platform Jellyfin client"
|
||||
arch=('x86_64')
|
||||
url="https://gitea.tourolle.paris/dtourolle/jellytau"
|
||||
license=('MIT')
|
||||
# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
|
||||
depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
|
||||
makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
|
||||
options=('!strip' '!lto')
|
||||
|
||||
# Populated from the working tree by scripts/build-arch.sh (SRC env var).
|
||||
_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
|
||||
|
||||
build() {
|
||||
cd "$_srcdir"
|
||||
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
|
||||
bun install --frozen-lockfile || bun install
|
||||
bun run build
|
||||
# Only the raw binary is needed; packaging is done in package() below so we
|
||||
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
|
||||
(cd src-tauri && cargo build --release --locked)
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$_srcdir"
|
||||
|
||||
install -Dm755 "src-tauri/target/release/jellytau" \
|
||||
"$pkgdir/usr/bin/jellytau"
|
||||
|
||||
# Desktop entry
|
||||
install -Dm644 "packaging/arch/jellytau.desktop" \
|
||||
"$pkgdir/usr/share/applications/jellytau.desktop"
|
||||
|
||||
# Icons (hicolor)
|
||||
install -Dm644 "src-tauri/icons/32x32.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
|
||||
install -Dm644 "src-tauri/icons/128x128@2x.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=JellyTau
|
||||
Comment=A cross-platform Jellyfin client
|
||||
Exec=jellytau
|
||||
Icon=jellytau
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Player;Audio;Video;
|
||||
StartupWMClass=jellytau
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
|
||||
#
|
||||
# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
|
||||
# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
|
||||
# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
|
||||
# and refuses to run as root, so run it as a non-root user with sudo for deps.
|
||||
#
|
||||
# Usage (typically inside the arch-build Docker stage as a non-root user):
|
||||
# scripts/build-arch.sh
|
||||
# OUTPUT_DIR=/app/dist scripts/build-arch.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT/packaging/arch"
|
||||
|
||||
echo "🏛️ Building JellyTau Arch package"
|
||||
echo "=================================="
|
||||
|
||||
# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
|
||||
export JELLYTAU_SRC="$REPO_ROOT"
|
||||
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
|
||||
|
||||
# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
|
||||
makepkg -sf --noconfirm
|
||||
|
||||
echo ""
|
||||
echo "✅ Built Arch package(s):"
|
||||
ls -1 ./*.pkg.tar.zst
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# Build Linux desktop packages (deb + rpm) for JellyTau.
|
||||
#
|
||||
# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
|
||||
# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
|
||||
# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
|
||||
# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-desktop-linux.sh # deb + rpm
|
||||
# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
|
||||
# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BUNDLES="${BUNDLES:-deb,rpm}"
|
||||
|
||||
echo "🐧 Building JellyTau Linux desktop packages"
|
||||
echo "==========================================="
|
||||
echo "Bundles: $BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --bundles overrides tauri.conf.json bundle.targets so this script controls
|
||||
# exactly which Linux formats are produced (never NSIS here).
|
||||
bun run tauri build --bundles "$BUNDLES"
|
||||
|
||||
BUNDLE_ROOT="src-tauri/target/release/bundle"
|
||||
echo ""
|
||||
echo "✅ Built packages:"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
|
||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
echo ""
|
||||
echo "📦 Copied bundles to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
|
||||
#
|
||||
# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
|
||||
# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
|
||||
# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
|
||||
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
|
||||
# it can bundle the NSIS installer from a Linux host.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
# - cargo-xwin (cargo install --locked cargo-xwin)
|
||||
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
||||
# - nsis (makensis) (installer generator)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-windows-cross.sh # exe + NSIS installer
|
||||
# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
|
||||
# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="x86_64-pc-windows-msvc"
|
||||
WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
|
||||
|
||||
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
|
||||
echo "================================================================"
|
||||
echo "Video plays via WebView2; audio via the webview <audio> backend."
|
||||
echo "Bundles: $WIN_BUNDLES"
|
||||
echo ""
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
|
||||
# a real Windows build and enable the nsis/msi bundlers on a Linux host.
|
||||
#
|
||||
# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
|
||||
# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
|
||||
# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
|
||||
# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
|
||||
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
||||
# that CLI validation — the bundler then picks nsis once it knows the target is
|
||||
# Windows.
|
||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||
else
|
||||
bun run tauri build --runner cargo-xwin --target "$TARGET"
|
||||
fi
|
||||
|
||||
BIN_DIR="src-tauri/target/$TARGET/release"
|
||||
echo ""
|
||||
echo "✅ Built Windows artifacts:"
|
||||
find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
|
||||
|
||||
if [[ -n "${OUTPUT_DIR:-}" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
|
||||
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
|
||||
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
|
||||
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
|
||||
echo ""
|
||||
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
|
||||
fi
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
||||
#
|
||||
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
||||
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
|
||||
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
|
||||
# that motivated this check.
|
||||
#
|
||||
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
|
||||
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
|
||||
# targets the one machine-detectable signature of the leak class — a *query* that
|
||||
# names a multi-type category — and defers everything subtler to the human
|
||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
||||
# present.
|
||||
#
|
||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
||||
# and is not matched.
|
||||
#
|
||||
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
|
||||
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
|
||||
# signal to push taxonomy into Rust, not to keep appending here.
|
||||
ALLOWLIST=(
|
||||
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
|
||||
# two-type filmography query with no category-configuration behind it. Tracked
|
||||
# as acceptable pending any person-scope work; revisit if it grows.
|
||||
"src/lib/components/library/PersonDetailView.svelte"
|
||||
)
|
||||
|
||||
is_allowed() {
|
||||
local file="$1"
|
||||
for allowed in "${ALLOWLIST[@]}"; do
|
||||
[[ "$file" == "$allowed" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
||||
# The comma inside the brackets is what makes it multi-type.
|
||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
||||
|
||||
echo "🔎 Checking frontend for domain-taxonomy leaks (multi-type query arrays)…"
|
||||
|
||||
# Collect hits, excluding tests and the allowlist.
|
||||
violations=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
file="${line%%:*}"
|
||||
case "$file" in
|
||||
*.test.*) continue ;;
|
||||
esac
|
||||
if is_allowed "$file"; then
|
||||
echo " ⏭️ allowlisted: $line"
|
||||
continue
|
||||
fi
|
||||
violations+="$line"$'\n'
|
||||
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$violations" ]]; then
|
||||
echo ""
|
||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
||||
echo " send an opaque scope and let the backend expand it to item types."
|
||||
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||
echo ""
|
||||
echo "$violations" | sed 's/^/ /'
|
||||
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
|
||||
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No multi-type taxonomy queries in the frontend."
|
||||
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
|
||||
echo " the real gate for subtler leaks.)"
|
||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
@@ -125,6 +125,11 @@ class MainActivity : TauriActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
NetworkTypeMonitor.stopWatching(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: android.content.res.Configuration
|
||||
@@ -214,6 +219,35 @@ class MainActivity : TauriActivity() {
|
||||
}, "AndroidBackgroundAudio")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
|
||||
|
||||
// Network transport reporting for the WiFi-only download gate (UR-053).
|
||||
// The frontend polls these on demand and re-pumps the download queue when
|
||||
// the 'jellytau-network-changed' event fires.
|
||||
webView.addJavascriptInterface(object : Any() {
|
||||
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
|
||||
@JavascriptInterface
|
||||
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
|
||||
|
||||
/** Whether the active network is unmetered. */
|
||||
@JavascriptInterface
|
||||
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
|
||||
|
||||
/** Whether downloads may run given the wifi-only preference. */
|
||||
@JavascriptInterface
|
||||
fun isAcceptable(wifiOnly: Boolean): Boolean =
|
||||
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
|
||||
|
||||
/** Whether native network detection is available at all (false on non-Android). */
|
||||
@JavascriptInterface
|
||||
fun isSupported(): Boolean = true
|
||||
}, "AndroidNetworkType")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
||||
|
||||
// Push network changes into the WebView so a queue blocked on "waiting for
|
||||
// WiFi" resumes the moment an acceptable network appears.
|
||||
NetworkTypeMonitor.startWatching(this) {
|
||||
dispatchWebEvent("jellytau-network-changed")
|
||||
}
|
||||
|
||||
// Set WebChromeClient to handle video playback and audio focus
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.dtourolle.jellytau
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
|
||||
/**
|
||||
* Reports the *kind* of network the device is on, so downloads can be gated on
|
||||
* "unmetered only" (the WiFi-only setting).
|
||||
*
|
||||
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
|
||||
* answers a different question: whether the Jellyfin *server* is reachable,
|
||||
* derived from real request outcomes. Reachability and transport type are
|
||||
* orthogonal — you can be on WiFi with a dead server, or on cellular with a
|
||||
* perfectly reachable one.
|
||||
*
|
||||
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
|
||||
* and we report UNKNOWN (which the gate treats as "not acceptable" when
|
||||
* wifi-only is on, failing closed rather than burning mobile data).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
object NetworkTypeMonitor {
|
||||
private const val TAG = "NetworkTypeMonitor"
|
||||
|
||||
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
|
||||
const val TYPE_NONE = "none"
|
||||
const val TYPE_WIFI = "wifi"
|
||||
const val TYPE_ETHERNET = "ethernet"
|
||||
const val TYPE_CELLULAR = "cellular"
|
||||
const val TYPE_OTHER = "other"
|
||||
const val TYPE_UNKNOWN = "unknown"
|
||||
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** Invoked on any network change; set by [startWatching]. */
|
||||
@Volatile
|
||||
private var onChange: (() -> Unit)? = null
|
||||
|
||||
private fun connectivityManager(context: Context): ConnectivityManager? =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
|
||||
/**
|
||||
* Current transport type of the active network.
|
||||
*
|
||||
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
|
||||
* distinguish "definitely offline" from "couldn't tell".
|
||||
*/
|
||||
fun currentType(context: Context): String {
|
||||
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
|
||||
val network = cm.activeNetwork ?: return TYPE_NONE
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
|
||||
|
||||
return when {
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
|
||||
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
|
||||
else -> TYPE_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the active network is unmetered.
|
||||
*
|
||||
* This is the bit that actually matters for the WiFi-only gate: a phone
|
||||
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
|
||||
* cellular data the setting exists to protect. Checking NOT_METERED rather
|
||||
* than the transport alone means tethering doesn't quietly burn a data plan.
|
||||
*/
|
||||
fun isUnmetered(context: Context): Boolean {
|
||||
val cm = connectivityManager(context) ?: return false
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether downloads may run right now given the wifi-only preference.
|
||||
*
|
||||
* Ethernet counts as acceptable (it is unmetered in practice and is what
|
||||
* Android TV devices use). Cellular never does. When wifi-only is off this is
|
||||
* always true — the gate simply isn't engaged.
|
||||
*/
|
||||
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
|
||||
if (!wifiOnly) return true
|
||||
val type = currentType(context)
|
||||
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
|
||||
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
|
||||
return isUnmetered(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the network changes, so a blocked
|
||||
* download queue can be re-pumped the moment an acceptable network appears.
|
||||
* Without this the queue would stall until some unrelated event pumped it.
|
||||
*
|
||||
* Idempotent: a second call replaces the previous callback.
|
||||
*/
|
||||
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
|
||||
val cm = connectivityManager(context) ?: run {
|
||||
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
|
||||
return
|
||||
}
|
||||
|
||||
stopWatching(context)
|
||||
onChange = onNetworkChanged
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
android.util.Log.d(TAG, "Network available")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
android.util.Log.d(TAG, "Network lost")
|
||||
onChange?.invoke()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
|
||||
// Fires when e.g. metered-ness flips without the network itself changing.
|
||||
onChange?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cm.registerNetworkCallback(request, cb)
|
||||
callback = cb
|
||||
android.util.Log.d(TAG, "Network callback registered")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "Failed to register network callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Unregister the network callback, if one is active. */
|
||||
fun stopWatching(context: Context) {
|
||||
val cb = callback ?: return
|
||||
val cm = connectivityManager(context)
|
||||
try {
|
||||
cm?.unregisterNetworkCallback(cb)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Failed to unregister network callback", e)
|
||||
}
|
||||
callback = null
|
||||
onChange = null
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,12 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// IT-017: a download queued from a greyed-out offline catalog entry
|
||||
/// (pending, `stream_url IS NULL`) persists, and on reconnect its URL is
|
||||
/// resolved and the row is healed (URL + target dir) so the pump can start
|
||||
/// it — while already-resolved rows are left untouched.
|
||||
///
|
||||
/// TRACES: UR-052, UR-011 | IT-017
|
||||
#[tokio::test]
|
||||
async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
|
||||
let db = test_db();
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex};
|
||||
use tauri::{Manager, State};
|
||||
|
||||
use super::{DatabaseWrapper, SmartCacheWrapper};
|
||||
use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
|
||||
use crate::download::{DownloadInfo, DownloadManager};
|
||||
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
|
||||
|
||||
@@ -21,6 +22,80 @@ pub use smart_cache::*;
|
||||
/// Wrapper for DownloadManager to be used as Tauri state
|
||||
pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
|
||||
|
||||
/// Wrapper for the current network transport, used by the WiFi-only gate.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub struct NetworkStateWrapper(pub NetworkStateHandle);
|
||||
|
||||
/// Report the device's current network transport (Android → Rust).
|
||||
///
|
||||
/// The frontend calls this on startup and whenever the native network callback
|
||||
/// fires. Updating to an acceptable network re-pumps the download queue, so a
|
||||
/// queue parked on "waiting for WiFi" drains itself without user action.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_network_state(
|
||||
app: tauri::AppHandle,
|
||||
network: NetworkStateWrapperArg,
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
download_manager: State<'_, DownloadManagerWrapper>,
|
||||
) -> Result<(), String> {
|
||||
let new_state = NetworkState {
|
||||
network_type: network.network_type,
|
||||
unmetered: network.unmetered,
|
||||
};
|
||||
|
||||
let handle = app.state::<NetworkStateWrapper>().0.clone();
|
||||
let previous = handle.get().await;
|
||||
handle.set(new_state).await;
|
||||
|
||||
if previous != new_state {
|
||||
info!(
|
||||
"[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
|
||||
previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
|
||||
);
|
||||
}
|
||||
|
||||
// If the new network unblocks the gate, drain whatever was waiting.
|
||||
if downloads_allowed_on_current_network(&app).await {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
let active = {
|
||||
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
|
||||
manager.get_active_downloads()
|
||||
};
|
||||
pump_download_queue(app.clone(), db_service, active).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Argument struct for [`set_network_state`].
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkStateWrapperArg {
|
||||
pub network_type: NetworkType,
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
/// Whether downloads are currently permitted by the WiFi-only gate.
|
||||
///
|
||||
/// The downloads UI uses this to render "Waiting for WiFi" on pending rows
|
||||
/// rather than leaving them looking silently stuck.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
|
||||
Ok(downloads_allowed_on_current_network(&app).await)
|
||||
}
|
||||
|
||||
/// Download statistics computed server-side
|
||||
#[allow(dead_code)]
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -1213,6 +1288,37 @@ pub async fn enqueue_video_downloads(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the current network permits downloads, given the user's WiFi-only
|
||||
/// preference.
|
||||
///
|
||||
/// Reads `wifi_only` from the SmartCache config (the single home of the
|
||||
/// setting) and checks it against the transport reported by the platform. On
|
||||
/// desktop the transport defaults to unmetered ethernet, so this is always
|
||||
/// true there.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
|
||||
let wifi_only = {
|
||||
let smart_cache = app.state::<SmartCacheWrapper>();
|
||||
let cache = match smart_cache.0.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("[pump] Failed to lock smart cache: {}", e);
|
||||
// Fail open: a lock problem must not silently wedge downloads.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
|
||||
let network = app.state::<NetworkStateWrapper>();
|
||||
network.0.allows_download(true).await
|
||||
}
|
||||
|
||||
/// Start as many pending downloads as there are free concurrency slots.
|
||||
///
|
||||
/// Picks the highest-priority `pending` rows that have a persisted `stream_url`
|
||||
@@ -1227,6 +1333,16 @@ pub(crate) async fn pump_download_queue(
|
||||
use crate::download::events::DownloadEvent;
|
||||
use tauri::Emitter;
|
||||
|
||||
// WiFi-only gate (UR-053): when the user has restricted downloads to
|
||||
// unmetered networks and we're on cellular (or can't tell), leave every
|
||||
// pending row exactly as it is. They stay 'pending' and the Android
|
||||
// network callback re-pumps us as soon as an acceptable network appears.
|
||||
if !downloads_allowed_on_current_network(&app).await {
|
||||
info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
|
||||
let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_concurrent = {
|
||||
let manager = app.state::<DownloadManagerWrapper>();
|
||||
let manager = match manager.0.lock() {
|
||||
@@ -1799,6 +1915,76 @@ pub async fn delete_album_downloads(
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Remove every completed download at or under a container item.
|
||||
///
|
||||
/// Works at any level of the Downloaded browse: a leaf (removes just that
|
||||
/// download), an album/season/series (removes all downloaded descendants linked
|
||||
/// via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
|
||||
/// on-disk files. Returns the number of downloads removed. Idempotent.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn delete_downloads_under(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
) -> Result<i64, String> {
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
// The item itself, or any child linked to it by container id.
|
||||
const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
|
||||
AND (
|
||||
d.item_id = ?
|
||||
OR d.item_id IN (
|
||||
SELECT c.id FROM items c
|
||||
WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
|
||||
)
|
||||
)";
|
||||
|
||||
let file_query = Query::with_params(
|
||||
&format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
|
||||
vec![
|
||||
QueryParam::String(user_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
],
|
||||
);
|
||||
let file_paths: Vec<String> = db_service
|
||||
.query_many(file_query, |row| row.get(0))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let delete_query = Query::with_params(
|
||||
&format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
|
||||
vec![
|
||||
QueryParam::String(user_id),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id.clone()),
|
||||
QueryParam::String(item_id),
|
||||
],
|
||||
);
|
||||
let deleted_count = db_service
|
||||
.execute(delete_query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for path in file_paths {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.part", path));
|
||||
}
|
||||
|
||||
Ok(deleted_count as i64)
|
||||
}
|
||||
|
||||
/// Download manager statistics
|
||||
#[derive(specta::Type, Debug, Clone, serde::Serialize)]
|
||||
pub struct DownloadManagerStats {
|
||||
|
||||
@@ -106,6 +106,8 @@ pub struct MergedMediaItem {
|
||||
pub album_id: Option<String>,
|
||||
pub duration: Option<f64>,
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier — replaces `primary_image_tag` (same value).
|
||||
pub image_id: Option<String>,
|
||||
pub media_type: String,
|
||||
}
|
||||
|
||||
@@ -120,6 +122,7 @@ impl From<&crate::player::MediaItem> for MergedMediaItem {
|
||||
album_id: item.album_id.clone(),
|
||||
duration: item.duration,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
image_id: item.primary_image_tag.clone(),
|
||||
media_type: match item.media_type {
|
||||
crate::player::MediaType::Audio => "audio".to_string(),
|
||||
crate::player::MediaType::Video => "video".to_string(),
|
||||
@@ -142,6 +145,7 @@ impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
|
||||
album_id: item.album_id.clone(),
|
||||
duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
image_id: item.primary_image_tag.clone(),
|
||||
media_type: item
|
||||
.item_type
|
||||
.clone()
|
||||
@@ -201,6 +205,15 @@ pub struct PlayItemRequest {
|
||||
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||
#[serde(default)]
|
||||
pub duration_seconds: Option<f64>,
|
||||
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
|
||||
/// background-audio handoff so an episode played as audio-only is still
|
||||
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
|
||||
#[serde(default)]
|
||||
pub item_type: Option<String>,
|
||||
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||
/// look up the next episode when a background-audio track ends.
|
||||
#[serde(default)]
|
||||
pub series_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Queue context for remote transfer - what type of queue is this?
|
||||
@@ -361,10 +374,11 @@ pub(super) async fn create_media_item(
|
||||
artist_items: None, // Not available from video-only request
|
||||
artists: None, // Not available from video-only request
|
||||
primary_image_tag: None, // Not available from video-only request
|
||||
item_type: None, // Not available from video-only request
|
||||
playlist_id: None, // Not available from video-only request
|
||||
duration: None, // Not available from video-only request
|
||||
artwork_url: None, // Not available from video-only request
|
||||
image_id: None,
|
||||
item_type: None, // Not available from video-only request
|
||||
playlist_id: None, // Not available from video-only request
|
||||
duration: None, // Not available from video-only request
|
||||
artwork_url: None, // Not available from video-only request
|
||||
media_type: crate::player::MediaType::Video, // Video-only request
|
||||
source,
|
||||
video_codec: Some(req.video_codec),
|
||||
@@ -595,7 +609,10 @@ pub async fn player_enter_background_audio(
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
item_type: None,
|
||||
image_id: item.primary_image_tag.clone(),
|
||||
// Carry episode identity so autoplay can advance to the next episode when
|
||||
// this audio-only handoff ends while backgrounded (UR-040).
|
||||
item_type: item.item_type.clone(),
|
||||
playlist_id: None,
|
||||
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
||||
duration: item.duration_seconds,
|
||||
@@ -610,7 +627,7 @@ pub async fn player_enter_background_audio(
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
series_id: item.series_id.clone(),
|
||||
server_id: item.server_id.clone(),
|
||||
};
|
||||
|
||||
@@ -1771,6 +1788,7 @@ pub async fn player_play_album_track(
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
@@ -1966,6 +1984,7 @@ pub async fn player_play_tracks(
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None, // Set based on context below
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
@@ -2428,6 +2447,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
|
||||
@@ -211,6 +211,7 @@ pub async fn player_add_track_by_id(
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
@@ -329,6 +330,7 @@ pub async fn player_add_tracks_by_ids(
|
||||
artist_items: track.artist_items.clone(), // For clickable artist links
|
||||
artists: track.artists.clone(), // Fallback artist info
|
||||
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
|
||||
image_id: track.primary_image_tag.clone(),
|
||||
item_type: Some(track.item_type.clone()), // Frontend compatibility
|
||||
playlist_id: None,
|
||||
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Audio and video playback settings commands.
|
||||
//!
|
||||
//! TRACES: UR-022, UR-031, UR-032, UR-033 | DR-025, DR-034, DR-035, DR-036
|
||||
//! TRACES: UR-022, UR-027, UR-031, UR-032, UR-033 | DR-025, DR-030, DR-034, DR-035, DR-036, IR-020
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use super::{PlayerStateWrapper, VideoSettingsWrapper};
|
||||
use crate::player::AutoplaySettings;
|
||||
use crate::settings::{AudioSettings, VideoSettings};
|
||||
use crate::settings::{AudioSettings, EqPreset, VideoSettings};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -14,13 +14,32 @@ pub async fn player_set_audio_settings(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
settings: AudioSettings,
|
||||
) -> Result<AudioSettings, String> {
|
||||
// Validate/normalise domain values before applying: clamp crossfade to its
|
||||
// range and normalise the equalizer band vector (length + gain clamps).
|
||||
let validated = settings
|
||||
.with_crossfade_clamped()
|
||||
.with_equalizer_normalised();
|
||||
let mut controller = player.0.lock().await;
|
||||
controller
|
||||
.set_audio_settings(&settings)
|
||||
.set_audio_settings(&validated)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(controller.audio_settings())
|
||||
}
|
||||
|
||||
/// The built-in equalizer presets and their per-band gain curves (dB), for the
|
||||
/// settings UI. The curve numbers are domain data defined by the band layout,
|
||||
/// so the frontend reads them here rather than encoding them.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_eq_presets() -> Result<Vec<(EqPreset, Vec<f32>)>, String> {
|
||||
Ok(EqPreset::ALL
|
||||
.iter()
|
||||
.map(|p| (*p, p.gains().to_vec()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_audio_settings(
|
||||
|
||||
@@ -195,6 +195,56 @@ pub async fn repository_get_item(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: libraries that contain downloaded content.
|
||||
///
|
||||
/// Backs the Downloads "Downloaded" surface. Never merges server results and is
|
||||
/// authoritative — an empty list means nothing is downloaded.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_libraries(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<Vec<Library>, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_libraries()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Downloaded-only browse: items under a container that are on the device.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_downloaded_items(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
parent_id: String,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_downloaded_items(&parent_id, options)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content (device total, per-item/container bytes).
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_download_disk_usage(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
) -> Result<DownloadDiskUsage, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.get_download_disk_usage()
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Query the optional JRay plugin for the actors on screen at time `t`
|
||||
/// (seconds) in an item. Returns an empty list when JRay isn't installed or
|
||||
/// has no data for the item, so the caller can render nothing without error.
|
||||
@@ -529,8 +579,9 @@ pub async fn repository_report_playback_start(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.report_playback_start(&item_id, position_ticks)
|
||||
@@ -545,8 +596,9 @@ pub async fn repository_report_playback_progress(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.report_playback_progress(&item_id, position_ticks)
|
||||
@@ -561,8 +613,10 @@ pub async fn repository_report_playback_stopped(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
repo.as_ref()
|
||||
.report_playback_stopped(&item_id, position_ticks)
|
||||
|
||||
@@ -583,7 +583,9 @@ pub async fn storage_delete_user(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackProgress {
|
||||
pub item_id: String,
|
||||
pub position_ticks: i64,
|
||||
/// Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
|
||||
/// converted here so the frontend never sees ticks.
|
||||
pub position_ms: i64,
|
||||
pub is_played: bool,
|
||||
pub is_favorite: bool,
|
||||
pub play_count: i32,
|
||||
@@ -597,8 +599,11 @@ pub async fn storage_update_playback_progress(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
// The frontend speaks milliseconds; ticks are a Jellyfin storage detail that
|
||||
// stays on this side of the boundary. 10_000 ticks = 1 ms.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -649,12 +654,14 @@ pub async fn storage_update_playback_context(
|
||||
db: State<'_, DatabaseWrapper>,
|
||||
user_id: String,
|
||||
item_id: String,
|
||||
position_ticks: i64,
|
||||
position_ms: i64,
|
||||
context_type: Option<String>,
|
||||
context_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use crate::storage::db_service::{Query, QueryParam};
|
||||
|
||||
// Milliseconds in, Jellyfin ticks stored. 10_000 ticks = 1 ms.
|
||||
let position_ticks = position_ms * 10_000;
|
||||
let db_service = {
|
||||
let database = db.0.lock().map_err(|e| e.to_string())?;
|
||||
Arc::new(database.service())
|
||||
@@ -857,9 +864,10 @@ pub async fn storage_get_playback_progress(
|
||||
|
||||
db_service
|
||||
.query_optional(query, |row| {
|
||||
let position_ticks: i64 = row.get(1)?;
|
||||
Ok(PlaybackProgress {
|
||||
item_id: row.get(0)?,
|
||||
position_ticks: row.get(1)?,
|
||||
position_ms: position_ticks / 10_000,
|
||||
is_played: row.get::<_, i32>(2)? != 0,
|
||||
is_favorite: row.get::<_, i32>(3)? != 0,
|
||||
play_count: row.get(4)?,
|
||||
@@ -1495,7 +1503,7 @@ mod tests {
|
||||
fn test_playback_progress_serialization() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-123".to_string(),
|
||||
position_ticks: 150_000_000,
|
||||
position_ms: 150_000_000,
|
||||
is_played: true,
|
||||
is_favorite: false,
|
||||
play_count: 3,
|
||||
@@ -1512,7 +1520,7 @@ mod tests {
|
||||
fn test_playback_progress_played_status() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-456".to_string(),
|
||||
position_ticks: 0,
|
||||
position_ms: 0,
|
||||
is_played: true,
|
||||
is_favorite: true,
|
||||
play_count: 1,
|
||||
@@ -1530,7 +1538,7 @@ mod tests {
|
||||
fn test_playback_progress_not_played() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "item-789".to_string(),
|
||||
position_ticks: 30_000_000,
|
||||
position_ms: 30_000_000,
|
||||
is_played: false,
|
||||
is_favorite: false,
|
||||
play_count: 0,
|
||||
@@ -1600,7 +1608,7 @@ mod tests {
|
||||
fn test_playback_progress_camel_case() {
|
||||
let progress = PlaybackProgress {
|
||||
item_id: "i1".to_string(),
|
||||
position_ticks: 100,
|
||||
position_ms: 100,
|
||||
is_played: true,
|
||||
is_favorite: false,
|
||||
play_count: 1,
|
||||
@@ -1609,7 +1617,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&progress).unwrap();
|
||||
// Verify camelCase serialization
|
||||
assert!(json.contains("itemId"));
|
||||
assert!(json.contains("positionTicks"));
|
||||
assert!(json.contains("positionMs"));
|
||||
assert!(json.contains("isPlayed"));
|
||||
assert!(json.contains("isFavorite"));
|
||||
assert!(json.contains("playCount"));
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Jellyfin → domain translation.
|
||||
//!
|
||||
//! The ONLY place Jellyfin's vocabulary touches the domain model. Adding a
|
||||
//! second provider later means a sibling `from_<provider>.rs`; the domain types
|
||||
//! and every consumer stay untouched.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use super::media::{MediaKind, StreamKind};
|
||||
|
||||
/// Classify a Jellyfin media-stream `Type` string into a neutral [`StreamKind`].
|
||||
/// Total and panic-free.
|
||||
pub fn stream_kind_from_jellyfin(stream_type: &str) -> StreamKind {
|
||||
match stream_type {
|
||||
"Audio" => StreamKind::Audio,
|
||||
"Video" => StreamKind::Video,
|
||||
"Subtitle" => StreamKind::Subtitle,
|
||||
_ => StreamKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Jellyfin ticks per second (10 million). A tick is 100 ns.
|
||||
/// The frontend must never see ticks — this is where they die.
|
||||
const TICKS_PER_MILLISECOND: i64 = 10_000;
|
||||
|
||||
/// Convert a Jellyfin `RunTimeTicks` value to milliseconds.
|
||||
///
|
||||
/// Domain durations are milliseconds; ticks are a Jellyfin unit and stop here.
|
||||
pub fn ticks_to_ms(ticks: i64) -> i64 {
|
||||
ticks / TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
/// Classify a Jellyfin `Type` string into a neutral [`MediaKind`].
|
||||
///
|
||||
/// **Total and panic-free**: any unrecognised string maps to [`MediaKind::Other`]
|
||||
/// rather than failing. `is_folder` disambiguates the one Jellyfin type
|
||||
/// (`ChannelFolderItem`) whose kind depends on whether it is a container.
|
||||
///
|
||||
/// The recognised set is every `item_type` the frontend audit found in use
|
||||
/// (docs/specs/frontend-domain-model.md), plus the common cast/crew person
|
||||
/// subtypes Jellyfin returns in `People[].Type`.
|
||||
pub fn kind_from_jellyfin(item_type: &str, is_folder: bool) -> MediaKind {
|
||||
match item_type {
|
||||
// Music
|
||||
"Audio" | "MusicVideo" => MediaKind::Track,
|
||||
"MusicAlbum" => MediaKind::Album,
|
||||
"MusicArtist" | "AlbumArtist" => MediaKind::Artist,
|
||||
"Playlist" => MediaKind::Playlist,
|
||||
|
||||
// Video
|
||||
"Movie" => MediaKind::Movie,
|
||||
"Series" => MediaKind::Series,
|
||||
"Season" => MediaKind::Season,
|
||||
"Episode" => MediaKind::Episode,
|
||||
// A bare video leaf with no richer classification.
|
||||
"Video" => MediaKind::Movie,
|
||||
|
||||
// Cast / crew — Jellyfin uses both a "Person" item type and role-typed
|
||||
// people (Actor/Director/Writer/Composer/…) in People[].Type.
|
||||
"Person" | "Actor" | "Director" | "Writer" | "Composer" | "GuestStar" | "Producer" => {
|
||||
MediaKind::Person
|
||||
}
|
||||
|
||||
// A live TV channel: playable, but a non-seekable live stream.
|
||||
"TvChannel" | "LiveTvChannel" => MediaKind::LiveChannel,
|
||||
// A bare channel is a container the user drills into.
|
||||
"Channel" => MediaKind::Channel,
|
||||
|
||||
// Containers
|
||||
"Folder" | "CollectionFolder" | "UserView" | "BoxSet" => MediaKind::Folder,
|
||||
// ChannelFolderItem is a container when it is a folder, else a playable
|
||||
// channel leaf (distinct kind so the UI can route it to playback).
|
||||
"ChannelFolderItem" => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::ChannelItem
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown → safe sink. Never panics.
|
||||
_ => {
|
||||
if is_folder {
|
||||
MediaKind::Folder
|
||||
} else {
|
||||
MediaKind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticks_convert_to_milliseconds() {
|
||||
// 1 second = 10,000,000 ticks = 1000 ms
|
||||
assert_eq!(ticks_to_ms(10_000_000), 1000);
|
||||
// 90.5 s
|
||||
assert_eq!(ticks_to_ms(905_000_000), 90_500);
|
||||
assert_eq!(ticks_to_ms(0), 0);
|
||||
// Sub-millisecond truncates toward zero, not panics.
|
||||
assert_eq!(ticks_to_ms(9_999), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn music_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Audio", false), MediaKind::Track);
|
||||
assert_eq!(kind_from_jellyfin("MusicAlbum", true), MediaKind::Album);
|
||||
assert_eq!(kind_from_jellyfin("MusicArtist", true), MediaKind::Artist);
|
||||
assert_eq!(kind_from_jellyfin("Playlist", true), MediaKind::Playlist);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_types_map() {
|
||||
assert_eq!(kind_from_jellyfin("Movie", false), MediaKind::Movie);
|
||||
assert_eq!(kind_from_jellyfin("Series", true), MediaKind::Series);
|
||||
assert_eq!(kind_from_jellyfin("Season", true), MediaKind::Season);
|
||||
assert_eq!(kind_from_jellyfin("Episode", false), MediaKind::Episode);
|
||||
assert_eq!(kind_from_jellyfin("Video", false), MediaKind::Movie);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_and_role_types_map_to_person() {
|
||||
for t in ["Person", "Actor", "Director", "Writer", "Composer"] {
|
||||
assert_eq!(kind_from_jellyfin(t, false), MediaKind::Person, "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_and_container_types_map() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("TvChannel", false),
|
||||
MediaKind::LiveChannel
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("Channel", false), MediaKind::Channel);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("CollectionFolder", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("BoxSet", true), MediaKind::Folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_folder_item_disambiguates_on_is_folder() {
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("ChannelFolderItem", false),
|
||||
MediaKind::ChannelItem
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_kinds_map() {
|
||||
assert_eq!(stream_kind_from_jellyfin("Audio"), StreamKind::Audio);
|
||||
assert_eq!(stream_kind_from_jellyfin("Video"), StreamKind::Video);
|
||||
assert_eq!(stream_kind_from_jellyfin("Subtitle"), StreamKind::Subtitle);
|
||||
assert_eq!(
|
||||
stream_kind_from_jellyfin("EmbeddedImage"),
|
||||
StreamKind::Other
|
||||
);
|
||||
assert_eq!(stream_kind_from_jellyfin(""), StreamKind::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_type_never_panics_and_falls_back() {
|
||||
// The whole point: garbage in, safe kind out, no panic.
|
||||
assert_eq!(kind_from_jellyfin("Epis0de", false), MediaKind::Other);
|
||||
assert_eq!(kind_from_jellyfin("", false), MediaKind::Other);
|
||||
assert_eq!(
|
||||
kind_from_jellyfin("SomeFutureType", true),
|
||||
MediaKind::Folder
|
||||
);
|
||||
assert_eq!(kind_from_jellyfin("🎵unicode", false), MediaKind::Other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Canonical, provider-neutral media domain model.
|
||||
//!
|
||||
//! This is the *single source of truth* for what a media item is across the
|
||||
//! whole app. Rust (repositories, player, downloads) uses these types directly;
|
||||
//! the frontend consumes the tauri-specta-generated projection in
|
||||
//! `src/lib/api/bindings.ts`. There is no second hand-written copy in either
|
||||
//! language, so the model cannot drift.
|
||||
//!
|
||||
//! No provider (Jellyfin) vocabulary belongs in this file. Translation from a
|
||||
//! provider's wire shape lives beside it in `from_jellyfin.rs` and is the only
|
||||
//! place provider terms touch the domain type.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The kind of a media item — provider-neutral classification.
|
||||
///
|
||||
/// Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
|
||||
/// (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
|
||||
/// typo or an unhandled kind is a compile error on the frontend, not a silent
|
||||
/// runtime miss across ~127 comparison sites.
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MediaKind {
|
||||
// Music
|
||||
Track,
|
||||
Album,
|
||||
Artist,
|
||||
Playlist,
|
||||
// Video
|
||||
Movie,
|
||||
Series,
|
||||
Season,
|
||||
Episode,
|
||||
// Cast/crew
|
||||
Person,
|
||||
// Containers / live TV
|
||||
/// A channel *container* the user drills into (Jellyfin `Channel`).
|
||||
Channel,
|
||||
Folder,
|
||||
/// A live TV channel — playable, but a live stream with no seekable
|
||||
/// timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
|
||||
LiveChannel,
|
||||
/// A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
|
||||
/// not itself a folder) — e.g. a plugin-channel VOD item that has no
|
||||
/// dedicated item type but carries its own media streams. Playable and
|
||||
/// seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
|
||||
/// and from `Other` so the UI can route it to playback.
|
||||
ChannelItem,
|
||||
/// A kind we do not model explicitly. Reached only for provider item types
|
||||
/// that map to nothing meaningful; consumers treat it like an opaque
|
||||
/// container. The mapping must be *total* — it never panics — so this is the
|
||||
/// safe sink for unknown strings. Also the `Default`, so a defaulted
|
||||
/// `MediaItem` (see the dual-carry migration) is inert rather than a lie.
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
/// The kind of a media stream within an item (audio track, video track,
|
||||
/// subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StreamKind {
|
||||
Audio,
|
||||
Video,
|
||||
Subtitle,
|
||||
/// Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl MediaKind {
|
||||
/// True for kinds that are containers/collections rather than playable leaves.
|
||||
/// Presentation-neutral helper the backend can use for e.g. drill-vs-play.
|
||||
// Consumed by later migration phases (drill-vs-play routing); kept now so the
|
||||
// domain surface is complete alongside the type it describes.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_container(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
MediaKind::Album
|
||||
| MediaKind::Artist
|
||||
| MediaKind::Series
|
||||
| MediaKind::Season
|
||||
| MediaKind::Playlist
|
||||
| MediaKind::Channel
|
||||
| MediaKind::Folder
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Canonical, provider-neutral domain model — the single source of truth for
|
||||
//! the app's core data shapes, shared with the frontend via generated bindings.
|
||||
//!
|
||||
//! Spec: docs/specs/frontend-domain-model.md
|
||||
|
||||
pub mod from_jellyfin;
|
||||
pub mod media;
|
||||
|
||||
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
|
||||
pub use media::{MediaKind, StreamKind};
|
||||
@@ -64,11 +64,20 @@ impl SmartCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if should pre-cache queue items
|
||||
/// Check if should pre-cache queue items.
|
||||
///
|
||||
/// Note this deliberately does NOT consult `wifi_only`. It used to return
|
||||
/// `queue_precache_enabled && !wifi_only`, which disabled precaching
|
||||
/// outright whenever the user enabled WiFi-only — regardless of the network
|
||||
/// actually in use. The network check now lives in the download queue pump
|
||||
/// (`downloads_allowed_on_current_network`), which is the single gate for
|
||||
/// all download traffic, so this only answers "is precaching enabled?".
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn should_precache_queue(&self) -> bool {
|
||||
self.config
|
||||
.lock()
|
||||
.map(|cfg| cfg.queue_precache_enabled && !cfg.wifi_only)
|
||||
.map(|cfg| cfg.queue_precache_enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -282,6 +291,19 @@ mod tests {
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wifi_only_does_not_disable_precaching() {
|
||||
// wifi_only must not short-circuit precaching: the network gate lives in
|
||||
// the download pump, which checks the *actual* transport. Enabling
|
||||
// WiFi-only while on WiFi should still precache.
|
||||
let mut config = CacheConfig::default();
|
||||
config.queue_precache_enabled = true;
|
||||
config.wifi_only = true;
|
||||
|
||||
let cache = SmartCache::new(config);
|
||||
assert!(cache.should_precache_queue());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_storage_limit_check() {
|
||||
use crate::storage::db_service::RusqliteService;
|
||||
|
||||
@@ -41,6 +41,11 @@ pub enum DownloadEvent {
|
||||
/// Download cancelled
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Cancelled { download_id: i64, item_id: String },
|
||||
/// The queue is holding: WiFi-only is enabled and the current network is
|
||||
/// metered/cellular. Pending rows stay pending and resume on network change.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
WaitingForNetwork,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod cache;
|
||||
pub mod events;
|
||||
pub mod network;
|
||||
pub mod worker;
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Network transport classification for the WiFi-only download gate.
|
||||
//!
|
||||
//! This answers "what kind of connection are we on?", which is orthogonal to
|
||||
//! the `ConnectivityMonitor`'s "is the server reachable?". The download queue
|
||||
//! pump consults this before starting pending rows when the user has enabled
|
||||
//! WiFi-only downloads.
|
||||
//!
|
||||
//! On Android the real transport is read from `NetworkCapabilities` in
|
||||
//! `NetworkTypeMonitor.kt` and pushed in from the frontend. On desktop there is
|
||||
//! no metered-connection concept worth enforcing, so we report `Ethernet`,
|
||||
//! which is always acceptable — gating desktop downloads would be a regression.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Kind of network transport currently active.
|
||||
///
|
||||
/// Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
|
||||
/// in sync (the serde rename below is what the frontend sends).
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkType {
|
||||
/// No active network.
|
||||
None,
|
||||
/// WiFi (may still be metered — check `unmetered`).
|
||||
Wifi,
|
||||
/// Wired ethernet, typical on Android TV and desktop.
|
||||
Ethernet,
|
||||
/// Mobile data — never acceptable when wifi-only is enabled.
|
||||
Cellular,
|
||||
/// Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
|
||||
Other,
|
||||
/// Could not determine the transport.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Current network transport plus whether it is metered.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkState {
|
||||
pub network_type: NetworkType,
|
||||
/// Whether the active network is unmetered (Android `NET_CAPABILITY_NOT_METERED`).
|
||||
pub unmetered: bool,
|
||||
}
|
||||
|
||||
impl Default for NetworkState {
|
||||
fn default() -> Self {
|
||||
// Desktop default: wired and unmetered, so the gate never blocks there.
|
||||
// Android overwrites this as soon as the frontend reports the real state.
|
||||
Self {
|
||||
network_type: NetworkType::Ethernet,
|
||||
unmetered: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkState {
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
///
|
||||
/// Ethernet counts as acceptable — it is unmetered in practice and is what
|
||||
/// Android TV devices use. Cellular never does. `None`/`Unknown` fail
|
||||
/// closed: if we cannot tell what we are on, we do not spend the user's
|
||||
/// mobile data to find out.
|
||||
///
|
||||
/// TRACES: UR-053 | DR-074
|
||||
pub fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
if !wifi_only {
|
||||
return true;
|
||||
}
|
||||
match self.network_type {
|
||||
NetworkType::Cellular | NetworkType::None | NetworkType::Unknown => false,
|
||||
// Require unmetered so metered WiFi hotspots (backed by the very
|
||||
// cellular data this setting protects) are excluded too.
|
||||
NetworkType::Wifi | NetworkType::Ethernet | NetworkType::Other => self.unmetered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, mutable view of the current network transport.
|
||||
///
|
||||
/// Cheap to clone; the frontend updates it via `set_network_state` whenever
|
||||
/// Android reports a network change.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NetworkStateHandle {
|
||||
state: Arc<RwLock<NetworkState>>,
|
||||
}
|
||||
|
||||
impl NetworkStateHandle {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(NetworkState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self) -> NetworkState {
|
||||
*self.state.read().await
|
||||
}
|
||||
|
||||
pub async fn set(&self, new_state: NetworkState) {
|
||||
*self.state.write().await = new_state;
|
||||
}
|
||||
|
||||
/// Whether downloads may run right now given the wifi-only preference.
|
||||
pub async fn allows_download(&self, wifi_only: bool) -> bool {
|
||||
self.state.read().await.allows_download(wifi_only)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(network_type: NetworkType, unmetered: bool) -> NetworkState {
|
||||
NetworkState {
|
||||
network_type,
|
||||
unmetered,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_only_off_allows_every_transport() {
|
||||
for t in [
|
||||
NetworkType::None,
|
||||
NetworkType::Wifi,
|
||||
NetworkType::Ethernet,
|
||||
NetworkType::Cellular,
|
||||
NetworkType::Other,
|
||||
NetworkType::Unknown,
|
||||
] {
|
||||
assert!(
|
||||
state(t, false).allows_download(false),
|
||||
"{t:?} should be allowed when wifi_only is off"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cellular_is_blocked_when_wifi_only() {
|
||||
// Even if somehow flagged unmetered, cellular is never acceptable.
|
||||
assert!(!state(NetworkType::Cellular, true).allows_download(true));
|
||||
assert!(!state(NetworkType::Cellular, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmetered_wifi_and_ethernet_are_allowed() {
|
||||
assert!(state(NetworkType::Wifi, true).allows_download(true));
|
||||
assert!(state(NetworkType::Ethernet, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metered_wifi_is_blocked() {
|
||||
// A phone hotspot reports as WiFi but is metered — blocking it is the
|
||||
// whole point of checking NOT_METERED rather than the transport alone.
|
||||
assert!(!state(NetworkType::Wifi, false).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_none_fail_closed() {
|
||||
assert!(!state(NetworkType::Unknown, true).allows_download(true));
|
||||
assert!(!state(NetworkType::None, true).allows_download(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_default_is_never_gated() {
|
||||
assert!(NetworkState::default().allows_download(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_roundtrips_state() {
|
||||
let handle = NetworkStateHandle::new();
|
||||
assert!(handle.allows_download(true).await);
|
||||
|
||||
handle.set(state(NetworkType::Cellular, false)).await;
|
||||
assert!(!handle.allows_download(true).await);
|
||||
assert!(handle.allows_download(false).await);
|
||||
assert_eq!(handle.get().await.network_type, NetworkType::Cellular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_type_serializes_lowercase() {
|
||||
// Must match the string constants in NetworkTypeMonitor.kt.
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Wifi).unwrap(),
|
||||
"\"wifi\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&NetworkType::Cellular).unwrap(),
|
||||
"\"cellular\""
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
-3
@@ -2,6 +2,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod connectivity;
|
||||
mod credentials;
|
||||
mod domain;
|
||||
mod download;
|
||||
mod jellyfin;
|
||||
mod playback_mode;
|
||||
@@ -52,6 +53,7 @@ use commands::{
|
||||
delete_album_downloads,
|
||||
delete_all_downloads,
|
||||
delete_download,
|
||||
delete_downloads_under,
|
||||
// Device commands
|
||||
device_get_id,
|
||||
device_set_id,
|
||||
@@ -71,6 +73,7 @@ use commands::{
|
||||
get_download_manager_stats,
|
||||
get_download_storage_stats,
|
||||
get_downloads,
|
||||
get_downloads_allowed,
|
||||
get_smart_cache_config,
|
||||
get_smart_cache_stats,
|
||||
image_get_url,
|
||||
@@ -118,6 +121,7 @@ use commands::{
|
||||
player_get_audio_settings,
|
||||
player_get_autoplay_settings,
|
||||
player_get_cache_config,
|
||||
player_get_eq_presets,
|
||||
player_get_queue,
|
||||
// Session management commands
|
||||
player_get_session,
|
||||
@@ -179,6 +183,9 @@ use commands::{
|
||||
repository_get_audio_only_stream_url_for_video,
|
||||
repository_get_audio_stream_url,
|
||||
repository_get_channels,
|
||||
repository_get_download_disk_usage,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_genres,
|
||||
repository_get_image_url,
|
||||
repository_get_item,
|
||||
@@ -212,6 +219,7 @@ use commands::{
|
||||
// Session polling commands
|
||||
sessions_set_polling_hint,
|
||||
set_max_concurrent_downloads,
|
||||
set_network_state,
|
||||
set_show_server_catalog,
|
||||
start_download,
|
||||
// Storage commands
|
||||
@@ -508,6 +516,12 @@ fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str
|
||||
}
|
||||
|
||||
/// Create the appropriate player backend for the current platform.
|
||||
// playback_reporter/position_throttler are consumed only by the native audio
|
||||
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "linux", target_os = "android")),
|
||||
allow(unused_variables)
|
||||
)]
|
||||
fn create_player_backend(
|
||||
app_handle: tauri::AppHandle,
|
||||
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
||||
@@ -608,11 +622,19 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other platforms
|
||||
// Platforms with no native audio backend (e.g. Windows): render audio-only
|
||||
// playback through a webview <audio> element (all video already renders in
|
||||
// the webview). Falls back to NullBackend only if the backend can't init.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
{
|
||||
warn!("WARNING: No audio backend available for this platform");
|
||||
Box::new(NullBackend::new())
|
||||
info!("No native audio backend for this platform - using webview <audio> backend");
|
||||
match player::WebviewAudioBackend::new(_event_emitter) {
|
||||
Ok(backend) => Box::new(backend),
|
||||
Err(e) => {
|
||||
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
|
||||
Box::new(NullBackend::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +681,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
player_skip_to,
|
||||
player_set_audio_settings,
|
||||
player_get_audio_settings,
|
||||
player_get_eq_presets,
|
||||
player_set_video_settings,
|
||||
player_get_video_settings,
|
||||
// Sleep timer and autoplay commands
|
||||
@@ -770,6 +793,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
delete_download,
|
||||
delete_all_downloads,
|
||||
delete_album_downloads,
|
||||
delete_downloads_under,
|
||||
clear_stale_downloads,
|
||||
get_download_storage_stats,
|
||||
mark_download_completed,
|
||||
@@ -786,6 +810,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
get_smart_cache_stats,
|
||||
update_smart_cache_config,
|
||||
get_smart_cache_config,
|
||||
// WiFi-only download gate (UR-053)
|
||||
set_network_state,
|
||||
get_downloads_allowed,
|
||||
get_album_recommendations,
|
||||
get_album_affinity_status,
|
||||
// Pinning commands
|
||||
@@ -835,6 +862,9 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_libraries,
|
||||
repository_get_items,
|
||||
repository_get_item,
|
||||
repository_get_downloaded_libraries,
|
||||
repository_get_downloaded_items,
|
||||
repository_get_download_disk_usage,
|
||||
repository_jray_actors_at,
|
||||
repository_get_latest_items,
|
||||
repository_get_resume_items,
|
||||
@@ -1196,6 +1226,13 @@ pub fn run() {
|
||||
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
||||
app.manage(download_manager_wrapper);
|
||||
|
||||
// Current network transport, for the WiFi-only download gate (UR-053).
|
||||
// Defaults to unmetered ethernet so desktop is never gated; Android
|
||||
// overwrites it via set_network_state as soon as the UI starts.
|
||||
app.manage(commands::download::NetworkStateWrapper(
|
||||
download::network::NetworkStateHandle::new(),
|
||||
));
|
||||
|
||||
// Initialize connectivity monitor
|
||||
info!("[INIT] Initializing connectivity monitor...");
|
||||
let http_config = HttpConfig::default();
|
||||
|
||||
@@ -948,6 +948,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -979,6 +980,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
|
||||
@@ -858,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
if auto_advance {
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
// Background audio-only episode: the frontend that normally
|
||||
// performs the advance (goto /player/<id>) is suspended, so
|
||||
// the backend must load the next episode's audio-only stream
|
||||
// itself — otherwise playback just stops at the boundary.
|
||||
let is_bg_audio_episode =
|
||||
controller.lock().await.current_is_audio_episode();
|
||||
if is_bg_audio_episode {
|
||||
log::info!(
|
||||
"[Autoplay] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"[Autoplay] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else {
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
} else {
|
||||
// Foreground: frontend drives the advance off the countdown.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -332,6 +332,7 @@ mod tests {
|
||||
gapless_playback: false,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
backend.set_audio_settings(&settings).unwrap();
|
||||
@@ -380,6 +381,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -438,6 +440,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -490,6 +493,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
|
||||
@@ -156,6 +156,25 @@ pub enum PlayerStatusEvent {
|
||||
/// Target position in seconds (only meaningful for "seek").
|
||||
position: Option<f64>,
|
||||
},
|
||||
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
///
|
||||
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
/// element in the webview, mirroring how all video already renders through
|
||||
/// the webview `<video>`. The element then reports its state/position back
|
||||
/// through the `player_report_*` commands, so the Rust controller stays the
|
||||
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
/// via `ControlCommand`.
|
||||
WebviewAudioLoad {
|
||||
/// Stream URL for the `<audio>` element to play.
|
||||
url: String,
|
||||
/// Jellyfin item id, used as the media_id when reporting state back.
|
||||
media_id: Option<String>,
|
||||
/// Resume position in seconds (0 = start from the beginning).
|
||||
position: f64,
|
||||
/// Whether to begin playing immediately after loading.
|
||||
autoplay: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for emitting player events to the frontend.
|
||||
|
||||
@@ -67,9 +67,16 @@ pub struct MediaItem {
|
||||
/// Artists as array of strings (fallback when artist_items not available)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artists: Option<Vec<String>>,
|
||||
/// Primary image tag for artwork
|
||||
/// Primary image tag for artwork.
|
||||
///
|
||||
/// Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
|
||||
/// while the frontend migrates (docs/specs/frontend-domain-model.md).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier the frontend resolves to a URL — replaces
|
||||
/// `primary_image_tag`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_id: Option<String>,
|
||||
/// Item type (Audio, Movie, Episode, etc.)
|
||||
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
|
||||
pub item_type: Option<String>,
|
||||
@@ -345,6 +352,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -380,6 +388,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -414,6 +423,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -448,6 +458,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -489,6 +500,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: None,
|
||||
playlist_id: None,
|
||||
duration: None,
|
||||
@@ -523,6 +535,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(120.0),
|
||||
|
||||
+212
-10
@@ -22,6 +22,11 @@ pub mod android;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_backend;
|
||||
|
||||
// Platforms with no native audio backend (e.g. Windows) render audio-only
|
||||
// playback through a webview <audio> element, mirroring how all video renders.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
pub mod webview_audio_backend;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||
@@ -40,6 +45,9 @@ pub use android::ExoPlayerBackend;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use mpv_backend::MpvBackend;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
pub use webview_audio_backend::WebviewAudioBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::{
|
||||
disable_remote_volume, enable_remote_volume, get_detected_codecs, set_media_command_handler,
|
||||
@@ -650,6 +658,24 @@ impl PlayerController {
|
||||
self.queue.clone()
|
||||
}
|
||||
|
||||
/// True when the current item is a TV episode being played in audio-only
|
||||
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
|
||||
/// `MediaType::Audio`. Used to decide whether the backend must drive the
|
||||
/// next-episode advance itself (the frontend is suspended in the background).
|
||||
///
|
||||
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
|
||||
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn current_is_audio_episode(&self) -> bool {
|
||||
self.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.map(|item| {
|
||||
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
|
||||
/// sleep timer fires or the queue ends with repeat off). Pair with
|
||||
/// `emit_queue_changed` so the frontend hides the mini player.
|
||||
@@ -955,9 +981,11 @@ impl PlayerController {
|
||||
return Ok(AutoplayDecision::Stop);
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
// Only count TV episodes (not audio tracks or movies)
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
||||
// Only count TV episodes (not audio tracks or movies). Note an
|
||||
// episode played in background-audio mode is MediaType::Audio, so
|
||||
// rely on is_episode_item (which checks item_type) rather than the
|
||||
// media_type alone.
|
||||
let is_episode = self.is_episode_item(¤t).await;
|
||||
|
||||
if is_episode {
|
||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||
@@ -973,10 +1001,12 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
// For video episodes, fetch next episode and show popup
|
||||
// For episodes, fetch next episode and show popup.
|
||||
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
||||
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||
// It's here for the Android ExoPlayer path where episode items sit in the
|
||||
// backend queue — including background-audio mode, where the episode is a
|
||||
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
|
||||
if self.is_episode_item(¤t).await {
|
||||
let repo = self.repository.lock_safe().clone();
|
||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||
let next_ep_result = if let Some(repo) = &repo {
|
||||
@@ -1034,6 +1064,77 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to the next episode while playing audio-only in the background.
|
||||
///
|
||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||
/// which is unavailable when the app is backgrounded and the WebView is
|
||||
/// suspended. This drives the advance entirely in the backend: build the next
|
||||
/// episode's *audio-only* stream URL and load it into the native audio player,
|
||||
/// so playback continues without any frontend involvement (UR-040).
|
||||
///
|
||||
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
||||
///
|
||||
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
|
||||
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
/// TRACES: UR-040, UR-023 | DR-052
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub async fn advance_to_next_episode_audio_only(
|
||||
&self,
|
||||
next_episode_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let repo = self
|
||||
.repository
|
||||
.lock_safe()
|
||||
.clone()
|
||||
.ok_or_else(|| "No repository for background episode advance".to_string())?;
|
||||
|
||||
// Details for session metadata (title/series/artwork) and the stream URL.
|
||||
let next = repo
|
||||
.get_item(next_episode_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
|
||||
|
||||
// Audio-only transcode from the start of the episode (no resume offset —
|
||||
// a freshly-started next episode always plays from the beginning).
|
||||
let stream_url = repo
|
||||
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
||||
|
||||
let media_item = MediaItem {
|
||||
id: next.id.clone(),
|
||||
title: next.name.clone(),
|
||||
name: Some(next.name.clone()),
|
||||
artist: next.series_name.clone(),
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: next.primary_image_tag.clone(),
|
||||
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
|
||||
// Preserve episode identity so the NEXT end-of-track also advances.
|
||||
item_type: Some("Episode".to_string()),
|
||||
playlist_id: None,
|
||||
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id: next.id.clone(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: next.series_id.clone(),
|
||||
server_id: Some(next.server_id.clone()),
|
||||
};
|
||||
|
||||
self.play_item(media_item).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Handle video playback ended from HTML5 video element.
|
||||
///
|
||||
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||
@@ -1127,11 +1228,19 @@ impl PlayerController {
|
||||
Ok(AutoplayDecision::Stop)
|
||||
}
|
||||
|
||||
/// Check if a media item is an episode (has Jellyfin ID to query)
|
||||
/// Check if a media item is an episode (has Jellyfin ID to query).
|
||||
///
|
||||
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
|
||||
/// to the audio path for background playback (UR-040) is still recognised as
|
||||
/// an episode — otherwise autoplay would fall through to the queue-based
|
||||
/// audio path, find nothing next, and stop at the episode boundary. When the
|
||||
/// type is unknown we fall back to the historical heuristic (video == episode).
|
||||
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||
// For now, assume video items are episodes
|
||||
// In production, we'd check item metadata or query Jellyfin
|
||||
item.media_type == MediaType::Video
|
||||
match item.item_type.as_deref() {
|
||||
Some("Episode") => true,
|
||||
Some(_) => item.media_type == MediaType::Video,
|
||||
None => item.media_type == MediaType::Video,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch next episode for a series by looking up the season's episodes
|
||||
@@ -1397,6 +1506,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -2177,6 +2287,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
name: format!("Episode {}", index),
|
||||
item_type: "Episode".to_string(),
|
||||
kind: crate::domain::MediaKind::Episode,
|
||||
is_folder: false,
|
||||
server_id: "server".to_string(),
|
||||
parent_id: Some("season1".to_string()),
|
||||
@@ -2184,11 +2295,13 @@ mod tests {
|
||||
overview: None,
|
||||
genres: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
production_year: None,
|
||||
premiere_date: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -2299,6 +2412,15 @@ mod tests {
|
||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, repo_types::RepoError> {
|
||||
Ok(format!("http://example.com/{}-audio.mp3", item_id))
|
||||
}
|
||||
async fn get_live_tv_channels(
|
||||
&self,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
@@ -2491,6 +2613,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background audio-only mode (UR-040): a video episode is handed off to the
|
||||
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
|
||||
/// playing while the app is backgrounded. When that audio track ends, autoplay
|
||||
/// must STILL recognise it as an episode and offer the next one — otherwise
|
||||
/// playback just pauses at the episode boundary (the reported bug). The item
|
||||
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
|
||||
#[tokio::test]
|
||||
async fn test_playback_ended_background_audio_episode_advances() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
||||
let episode = MediaItem {
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||
series_id: Some("series1".to_string()),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
|
||||
// Clear the NewTrackLoaded reason to simulate natural track end.
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(next_episode.id, "ep3");
|
||||
}
|
||||
other => panic!(
|
||||
"background-audio episode end must advance to the next episode, got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend-driven advance (used when backgrounded) must load the next
|
||||
/// episode as an AUDIO item carrying its episode identity, so the *following*
|
||||
/// end-of-track also advances rather than stopping.
|
||||
#[tokio::test]
|
||||
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
controller
|
||||
.advance_to_next_episode_audio_only("ep2")
|
||||
.await
|
||||
.expect("advance should succeed");
|
||||
|
||||
let current = controller
|
||||
.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.cloned()
|
||||
.expect("an item should be loaded");
|
||||
assert_eq!(current.id, "ep2");
|
||||
assert_eq!(current.media_type, MediaType::Audio);
|
||||
assert_eq!(current.item_type.as_deref(), Some("Episode"));
|
||||
assert_eq!(current.series_id.as_deref(), Some("series1"));
|
||||
// Uses the audio-only URL, not a video stream.
|
||||
match ¤t.source {
|
||||
MediaSource::Remote { stream_url, .. } => {
|
||||
assert!(
|
||||
stream_url.contains("audio"),
|
||||
"expected audio-only URL, got {}",
|
||||
stream_url
|
||||
);
|
||||
}
|
||||
other => panic!("expected Remote source, got {:?}", other),
|
||||
}
|
||||
|
||||
// The controller now considers itself mid background-audio episode, so the
|
||||
// next end-of-track will advance again rather than stop.
|
||||
assert!(controller.current_is_audio_episode());
|
||||
}
|
||||
|
||||
/// Without a controller repository the Android episode path must still
|
||||
/// stop gracefully (previous behavior) rather than error.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::settings::{AudioSettings, VolumeLevel, EQ_BANDS};
|
||||
use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use libmpv::Mpv;
|
||||
@@ -552,8 +552,19 @@ impl PlayerBackend for MpvBackend {
|
||||
})?;
|
||||
}
|
||||
|
||||
// Audio filter chain: build a single lavfi graph combining the EQ
|
||||
// peaking bands and (optionally) a dynamic loudness normalizer, and
|
||||
// set the `af` property. An empty string clears all filters. Both
|
||||
// features share one `af` graph because MPV exposes a single filter
|
||||
// property. See docs/specs/audio-equalizer.md and IR-020.
|
||||
let af = build_af_filter(settings);
|
||||
self.mpv
|
||||
.set_property("af", af.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set audio filters: {:?}", e),
|
||||
})?;
|
||||
|
||||
// TODO: Implement crossfade via MPV audio filters if needed
|
||||
// TODO: Implement volume normalization if needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -563,9 +574,200 @@ impl PlayerBackend for MpvBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full MPV `af` (audio filter) value from the audio settings.
|
||||
///
|
||||
/// Combines the equalizer peaking bands and the loudness-normalization filter
|
||||
/// into a single `lavfi` graph, because MPV exposes one `af` property. The
|
||||
/// normalizer runs *after* the EQ so it levels the post-EQ signal. Returns an
|
||||
/// empty string when neither feature contributes a filter, which clears `af`.
|
||||
///
|
||||
/// TRACES: UR-027, UR-033 | IR-020, DR-036
|
||||
fn build_af_filter(settings: &AudioSettings) -> String {
|
||||
let mut entries = eq_filter_entries(settings.equalizer_enabled, &settings.equalizer_bands);
|
||||
if let Some(norm) = normalize_filter_entry(settings.normalize_volume, settings.volume_level) {
|
||||
entries.push(norm);
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("lavfi=[{}]", entries.join(","))
|
||||
}
|
||||
|
||||
/// Peaking-EQ filter entries (unwrapped), one ffmpeg `equalizer` (two-pole
|
||||
/// peaking) per band with a non-zero gain, e.g.
|
||||
/// `equalizer=f=31:width_type=o:width=1:g=5`. Returns an empty vec when the EQ
|
||||
/// is disabled or every gain is ~0. Gains are assumed already normalised by
|
||||
/// [`AudioSettings::with_equalizer_normalised`]; bands beyond [`EQ_BANDS`] are
|
||||
/// ignored.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020
|
||||
fn eq_filter_entries(enabled: bool, bands: &[f32]) -> Vec<String> {
|
||||
if !enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
bands
|
||||
.iter()
|
||||
.zip(EQ_BANDS.iter())
|
||||
.filter(|(gain, _)| gain.abs() >= 0.05) // skip ~0 dB bands
|
||||
.map(|(gain, freq)| {
|
||||
// width_type=o → octave bandwidth; width=1 → one octave per band.
|
||||
format!("equalizer=f={}:width_type=o:width=1:g={}", freq, gain)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reference peak (`dynaudnorm` `p`, linear amplitude) for the default
|
||||
/// [`VolumeLevel::Normal`] (−14 LUFS) target, leaving −1.2 dB of headroom.
|
||||
const NORMALIZE_REF_PEAK: f32 = 0.87;
|
||||
/// Reference loudness the peak table is anchored at (Normal preset, −14 LUFS).
|
||||
const NORMALIZE_REF_LUFS: f32 = -14.0;
|
||||
|
||||
/// The loudness-normalization filter entry (unwrapped), or `None` when
|
||||
/// normalization is disabled. Uses ffmpeg's `dynaudnorm`, a gentle real-time
|
||||
/// dynamic normalizer that avoids the gain "pumping" `loudnorm`'s single-pass
|
||||
/// mode can produce on very dynamic material.
|
||||
///
|
||||
/// `dynaudnorm` targets a peak amplitude (`p`, linear 0–1), not a LUFS value,
|
||||
/// so the Loud/Normal/Quiet presets become *approximate*: each preset's LUFS
|
||||
/// offset from the Normal reference is applied as a dB offset to the reference
|
||||
/// peak, preserving the Loud > Normal > Quiet ordering. `g=15` (gaussian window
|
||||
/// size) further smooths gain changes; the peak is clamped to a safe (0, 0.99]
|
||||
/// so loud presets never request full-scale.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036
|
||||
fn normalize_filter_entry(enabled: bool, level: VolumeLevel) -> Option<String> {
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
// LUFS above the reference → louder → higher peak; each +1 LUFS ≈ +1 dB.
|
||||
let db_offset = level.target_lufs() - NORMALIZE_REF_LUFS;
|
||||
let peak = (NORMALIZE_REF_PEAK * 10f32.powf(db_offset / 20.0)).clamp(0.10, 0.99);
|
||||
// 3 decimals is plenty for a peak target and keeps the filter string stable.
|
||||
Some(format!("dynaudnorm=p={:.3}:g=15", peak))
|
||||
}
|
||||
|
||||
impl Drop for MpvBackend {
|
||||
fn drop(&mut self) {
|
||||
info!("[MpvBackend] Shutting down");
|
||||
// MPV will be automatically cleaned up
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod af_filter_tests {
|
||||
use super::{build_af_filter, eq_filter_entries, normalize_filter_entry};
|
||||
use crate::settings::{AudioSettings, VolumeLevel};
|
||||
|
||||
fn settings() -> AudioSettings {
|
||||
AudioSettings {
|
||||
equalizer_enabled: false,
|
||||
equalizer_bands: vec![0.0; 10],
|
||||
normalize_volume: false,
|
||||
..AudioSettings::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Disabled EQ, or an all-zero curve, produces no EQ entries.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020 | UT-083
|
||||
#[test]
|
||||
fn test_eq_entries_empty_when_disabled_or_flat() {
|
||||
assert!(eq_filter_entries(false, &[5.0, -3.0, 2.0]).is_empty());
|
||||
assert!(eq_filter_entries(true, &[0.0; 10]).is_empty());
|
||||
// Sub-threshold gains count as flat.
|
||||
assert!(eq_filter_entries(true, &[0.01, -0.02]).is_empty());
|
||||
}
|
||||
|
||||
/// Enabled EQ builds one peaking `equalizer` per non-zero band at the right
|
||||
/// centre frequency and gain, chained inside a single `lavfi` filter.
|
||||
///
|
||||
/// TRACES: UR-027 | IR-020 | UT-084
|
||||
#[test]
|
||||
fn test_eq_filter_builds_lavfi_chain() {
|
||||
// First band (31 Hz) +5 dB, third band (125 Hz) -2 dB, rest flat.
|
||||
let mut s = settings();
|
||||
s.equalizer_enabled = true;
|
||||
s.equalizer_bands = vec![5.0, 0.0, -2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
let af = build_af_filter(&s);
|
||||
assert!(af.starts_with("lavfi=["), "wrapped in lavfi: {af}");
|
||||
assert!(af.ends_with("]"));
|
||||
assert!(af.contains("equalizer=f=31:width_type=o:width=1:g=5"));
|
||||
assert!(af.contains("equalizer=f=125:width_type=o:width=1:g=-2"));
|
||||
// Only two bands are non-zero → exactly two peaking filters.
|
||||
assert_eq!(af.matches("equalizer=").count(), 2);
|
||||
}
|
||||
|
||||
/// Disabled normalization yields no filter entry; the combined `af` for a
|
||||
/// fully default (all-off) settings is empty, which clears `af`.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036 | UT-085
|
||||
#[test]
|
||||
fn test_normalize_disabled_produces_no_filter() {
|
||||
assert!(normalize_filter_entry(false, VolumeLevel::Normal).is_none());
|
||||
assert_eq!(build_af_filter(&settings()), "");
|
||||
}
|
||||
|
||||
/// Enabled normalization emits a `dynaudnorm` filter with a peak target, and
|
||||
/// the peak preserves the Loud > Normal > Quiet ordering.
|
||||
///
|
||||
/// TRACES: UR-033 | DR-036 | UT-086
|
||||
#[test]
|
||||
fn test_normalize_peak_preserves_preset_ordering() {
|
||||
fn peak_of(entry: &str) -> f32 {
|
||||
// "dynaudnorm=p=0.870:g=15" → 0.870
|
||||
entry
|
||||
.split("p=")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(':').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("parseable peak")
|
||||
}
|
||||
|
||||
let loud = normalize_filter_entry(true, VolumeLevel::Loud).unwrap();
|
||||
let normal = normalize_filter_entry(true, VolumeLevel::Normal).unwrap();
|
||||
let quiet = normalize_filter_entry(true, VolumeLevel::Quiet).unwrap();
|
||||
for entry in [&loud, &normal, &quiet] {
|
||||
assert!(
|
||||
entry.starts_with("dynaudnorm="),
|
||||
"dynaudnorm filter: {entry}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
peak_of(&loud) > peak_of(&normal) && peak_of(&normal) > peak_of(&quiet),
|
||||
"Loud {} > Normal {} > Quiet {}",
|
||||
peak_of(&loud),
|
||||
peak_of(&normal),
|
||||
peak_of(&quiet),
|
||||
);
|
||||
// Every preset stays within the safe (0, 0.99] clamp.
|
||||
for p in [peak_of(&loud), peak_of(&normal), peak_of(&quiet)] {
|
||||
assert!(p > 0.0 && p <= 0.99, "peak in range: {p}");
|
||||
}
|
||||
|
||||
let mut s = settings();
|
||||
s.normalize_volume = true;
|
||||
s.volume_level = VolumeLevel::Quiet;
|
||||
let af = build_af_filter(&s);
|
||||
assert!(af.starts_with("lavfi=["));
|
||||
assert!(af.contains("dynaudnorm=p="));
|
||||
}
|
||||
|
||||
/// EQ and normalization coexist in one `lavfi` graph, with the normalizer
|
||||
/// placed after the EQ bands so it levels the post-EQ signal.
|
||||
///
|
||||
/// TRACES: UR-027, UR-033 | IR-020, DR-036 | UT-087
|
||||
#[test]
|
||||
fn test_eq_and_normalize_combine_in_order() {
|
||||
let mut s = settings();
|
||||
s.equalizer_enabled = true;
|
||||
s.equalizer_bands = vec![5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
|
||||
s.normalize_volume = true;
|
||||
s.volume_level = VolumeLevel::Normal;
|
||||
let af = build_af_filter(&s);
|
||||
|
||||
let eq_pos = af.find("equalizer=").expect("has EQ");
|
||||
let norm_pos = af.find("dynaudnorm=").expect("has normalizer");
|
||||
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +551,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
|
||||
@@ -242,6 +242,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Test Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(180.0),
|
||||
@@ -272,6 +273,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Movie".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(7200.0),
|
||||
|
||||
@@ -323,6 +323,7 @@ mod tests {
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Video".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(100.0),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Webview audio backend — audio-only playback for platforms without a native
|
||||
//! audio backend (currently Windows).
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//! All *video* already renders through the webview HTML5 `<video>` element on
|
||||
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
|
||||
//! *audio-only* (music) playback. On Windows there is no native audio backend,
|
||||
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
|
||||
//! silent.
|
||||
//!
|
||||
//! This backend fills that gap without any C dependency (so it still
|
||||
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
|
||||
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
|
||||
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
|
||||
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
|
||||
//! its real state/position back through the `player_report_*` commands, so the
|
||||
//! Rust `PlayerController` remains the single source of truth (the controller's
|
||||
//! `report_html5_*` methods fold those reports into the normal event pipeline).
|
||||
//!
|
||||
//! Because the reported state flows through the event pipeline (not through this
|
||||
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
|
||||
//! backend for HTML5-rendered media), this backend only needs to keep a
|
||||
//! best-effort local mirror for direct `player_get_state` queries.
|
||||
//!
|
||||
//! TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use log::{debug, info};
|
||||
|
||||
use super::backend::{PlayerBackend, PlayerError};
|
||||
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
|
||||
use super::media::{MediaItem, MediaSource};
|
||||
use super::state::PlayerState;
|
||||
use crate::settings::AudioSettings;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// Extract a webview-playable URL from a media item's source.
|
||||
///
|
||||
/// Remote/DirectUrl are HTTP(S) URLs the `<audio>` element can play directly.
|
||||
/// Local files would need the Tauri asset protocol (`convertFileSrc`) on the
|
||||
/// frontend; for now we pass the path through and let the frontend resolve it.
|
||||
fn stream_url(media: &MediaItem) -> String {
|
||||
match &media.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
MediaSource::DirectUrl { url } => url.clone(),
|
||||
MediaSource::Local { file_path, .. } => file_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
struct InternalState {
|
||||
current_media: Option<MediaItem>,
|
||||
volume: f32,
|
||||
position: f64,
|
||||
duration: Option<f64>,
|
||||
state: PlayerState,
|
||||
audio_settings: AudioSettings,
|
||||
}
|
||||
|
||||
pub struct WebviewAudioBackend {
|
||||
emitter: Arc<dyn PlayerEventEmitter>,
|
||||
state: Arc<std::sync::Mutex<InternalState>>,
|
||||
}
|
||||
|
||||
impl WebviewAudioBackend {
|
||||
pub fn new(emitter: Arc<dyn PlayerEventEmitter>) -> Result<Self, PlayerError> {
|
||||
info!("[WebviewAudioBackend] Initializing (audio renders in webview <audio>)");
|
||||
Ok(Self {
|
||||
emitter,
|
||||
state: Arc::new(std::sync::Mutex::new(InternalState {
|
||||
current_media: None,
|
||||
volume: 1.0,
|
||||
position: 0.0,
|
||||
duration: None,
|
||||
state: PlayerState::Idle,
|
||||
audio_settings: AudioSettings::default(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/// Emit a backend-originated control intent to the active frontend adapter
|
||||
/// (the webview `<audio>` element, via `playerEvents.ts` -> active adapter).
|
||||
fn emit_control(&self, action: &str, position: Option<f64>) {
|
||||
self.emitter.emit(PlayerStatusEvent::ControlCommand {
|
||||
action: action.to_string(),
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PlayerBackend for WebviewAudioBackend {
|
||||
fn load(&mut self, media: &MediaItem) -> Result<(), PlayerError> {
|
||||
let url = stream_url(media);
|
||||
info!("[WebviewAudioBackend] load: {} - {}", media.title, url);
|
||||
|
||||
{
|
||||
let mut st = self.state.lock_safe();
|
||||
st.current_media = Some(media.clone());
|
||||
st.position = 0.0;
|
||||
st.duration = media.duration;
|
||||
st.state = PlayerState::Loading {
|
||||
media: media.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
// Hand the URL to the frontend <audio> element. autoplay=true so a plain
|
||||
// load-then-play (the common queue-advance path) starts immediately; an
|
||||
// explicit pause afterwards is still honored via ControlCommand.
|
||||
self.emitter.emit(PlayerStatusEvent::WebviewAudioLoad {
|
||||
url,
|
||||
media_id: media.jellyfin_id().map(|s| s.to_string()),
|
||||
position: 0.0,
|
||||
autoplay: true,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] play");
|
||||
self.emit_control("play", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] pause");
|
||||
self.emit_control("pause", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] stop");
|
||||
{
|
||||
let mut st = self.state.lock_safe();
|
||||
st.current_media = None;
|
||||
st.position = 0.0;
|
||||
st.duration = None;
|
||||
st.state = PlayerState::Idle;
|
||||
}
|
||||
self.emit_control("stop", None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek(&mut self, position: f64) -> Result<(), PlayerError> {
|
||||
debug!("[WebviewAudioBackend] seek: {}", position);
|
||||
self.state.lock_safe().position = position;
|
||||
self.emit_control("seek", Some(position));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
|
||||
let clamped = volume.clamp(0.0, 1.0);
|
||||
self.state.lock_safe().volume = clamped;
|
||||
// Volume is applied on the element by the frontend, which observes the
|
||||
// volume via the player store; no dedicated ControlCommand action yet.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn position(&self) -> f64 {
|
||||
self.state.lock_safe().position
|
||||
}
|
||||
|
||||
fn duration(&self) -> Option<f64> {
|
||||
self.state.lock_safe().duration
|
||||
}
|
||||
|
||||
fn state(&self) -> PlayerState {
|
||||
self.state.lock_safe().state.clone()
|
||||
}
|
||||
|
||||
fn volume(&self) -> f32 {
|
||||
self.state.lock_safe().volume
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
self.state.lock_safe().audio_settings = settings.clone().with_crossfade_clamped();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_settings(&self) -> AudioSettings {
|
||||
self.state.lock_safe().audio_settings.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::player::events::PlayerStatusEvent;
|
||||
use crate::player::media::{MediaSource, MediaType};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
/// Test emitter that records everything emitted.
|
||||
struct RecordingEmitter {
|
||||
events: Arc<StdMutex<Vec<PlayerStatusEvent>>>,
|
||||
}
|
||||
|
||||
impl PlayerEventEmitter for RecordingEmitter {
|
||||
fn emit(&self, event: PlayerStatusEvent) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_media() -> MediaItem {
|
||||
MediaItem {
|
||||
id: "track1".to_string(),
|
||||
title: "Song".to_string(),
|
||||
name: Some("Song".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
album_name: Some("Album".to_string()),
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: Some(vec!["Artist".to_string()]),
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
item_type: Some("Audio".to_string()),
|
||||
playlist_id: None,
|
||||
duration: Some(200.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::DirectUrl {
|
||||
url: "http://example.com/song.mp3".to_string(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: None,
|
||||
server_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn backend() -> (WebviewAudioBackend, Arc<StdMutex<Vec<PlayerStatusEvent>>>) {
|
||||
let events = Arc::new(StdMutex::new(Vec::new()));
|
||||
let emitter = Arc::new(RecordingEmitter {
|
||||
events: events.clone(),
|
||||
});
|
||||
(WebviewAudioBackend::new(emitter).unwrap(), events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_emits_webview_audio_load_with_url() {
|
||||
let (mut b, events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
let load = ev
|
||||
.iter()
|
||||
.find(|e| matches!(e, PlayerStatusEvent::WebviewAudioLoad { .. }))
|
||||
.expect("WebviewAudioLoad emitted");
|
||||
if let PlayerStatusEvent::WebviewAudioLoad { url, autoplay, .. } = load {
|
||||
assert_eq!(url, "http://example.com/song.mp3");
|
||||
assert!(*autoplay);
|
||||
}
|
||||
assert!(matches!(b.state(), PlayerState::Loading { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_seek_emit_control_commands() {
|
||||
let (mut b, events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
b.pause().unwrap();
|
||||
b.seek(42.0).unwrap();
|
||||
|
||||
let ev = events.lock().unwrap();
|
||||
assert!(ev.iter().any(|e| matches!(
|
||||
e,
|
||||
PlayerStatusEvent::ControlCommand { action, .. } if action == "pause"
|
||||
)));
|
||||
assert!(ev.iter().any(|e| matches!(
|
||||
e,
|
||||
PlayerStatusEvent::ControlCommand { action, position: Some(p) }
|
||||
if action == "seek" && (*p - 42.0).abs() < f64::EPSILON
|
||||
)));
|
||||
assert_eq!(b.position(), 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_is_clamped_and_stored() {
|
||||
let (mut b, _events) = backend();
|
||||
b.set_volume(1.5).unwrap();
|
||||
assert_eq!(b.volume(), 1.0);
|
||||
b.set_volume(-0.2).unwrap();
|
||||
assert_eq!(b.volume(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_resets_to_idle() {
|
||||
let (mut b, _events) = backend();
|
||||
b.load(&test_media()).unwrap();
|
||||
b.stop().unwrap();
|
||||
assert!(matches!(b.state(), PlayerState::Idle));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
// @req: IR-013 - SQLite integration for local database
|
||||
// @req: DR-012 - Local database for media metadata cache
|
||||
// @req: DR-013 - Repository pattern for online/offline data access
|
||||
//
|
||||
// TRACES: UR-002, UR-052 | IR-013 | DR-012, DR-013, DR-080
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::utils::lock::MutexSafe;
|
||||
@@ -131,6 +133,36 @@ impl HybridRepository {
|
||||
Ok(result.items)
|
||||
}
|
||||
|
||||
/// Browse downloaded content only — the dedicated Downloads surface.
|
||||
///
|
||||
/// Bypasses the cache/server merge entirely and reads the offline repository
|
||||
/// directly, so an empty result is authoritative ("nothing downloaded here")
|
||||
/// and never falls through to the server (DR-080). Available online too — a
|
||||
/// user who is reachable still wants to browse what's on the device.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
pub async fn get_downloaded_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
self.offline.get_downloaded_items(parent_id, options).await
|
||||
}
|
||||
|
||||
/// Libraries that contain downloaded content (offline-only, authoritative).
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
self.offline.get_downloaded_libraries().await
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content, for the disk-usage display.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
|
||||
self.offline.get_download_disk_usage().await
|
||||
}
|
||||
|
||||
/// Search only the live Jellyfin server (full library).
|
||||
pub async fn search_server_only(
|
||||
&self,
|
||||
@@ -316,6 +348,26 @@ impl MediaRepository for HybridRepository {
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await;
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
// for downloaded media only and this library has none. Return it as-is
|
||||
// rather than falling through to the server, which would re-pad the page
|
||||
// with the full catalog and re-defeat the filter (DR-080). When the flag
|
||||
// is on (the default, and always so while reachable) behaviour below is
|
||||
// unchanged, including the background cache refresh on a hit.
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
if let Ok(data) = &cache_result {
|
||||
debug!(
|
||||
"[HybridRepo] Downloads-only gate: returning offline result ({} items) as authoritative for parent {}",
|
||||
data.items.len(),
|
||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||
);
|
||||
// Abort the in-flight server request; we won't use it.
|
||||
server_handle.abort();
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache hit: return immediately, update cache in background
|
||||
if let Ok(data) = &cache_result {
|
||||
if data.has_content() {
|
||||
@@ -589,6 +641,24 @@ impl MediaRepository for HybridRepository {
|
||||
self.online.get_audio_stream_url(item_id).await
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
// Audio-only transcode of a video requires the server - delegate to online.
|
||||
self.online
|
||||
.build_audio_only_stream_url_for_video(
|
||||
item_id,
|
||||
media_source_id,
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV requires server communication - delegate to online repository
|
||||
self.online.get_live_tv_channels().await
|
||||
@@ -976,6 +1046,16 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1224,6 +1304,16 @@ mod tests {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1370,6 +1460,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
item_type: "Movie".to_string(),
|
||||
kind: crate::domain::MediaKind::Movie,
|
||||
is_folder: false,
|
||||
server_id: "test-server".to_string(),
|
||||
parent_id: Some("parent-123".to_string()),
|
||||
@@ -1377,11 +1468,13 @@ mod tests {
|
||||
overview: Some("Test overview".to_string()),
|
||||
genres: Some(vec!["Action".to_string(), "Adventure".to_string()]),
|
||||
runtime_ticks: Some(7200000000),
|
||||
duration_ms: Some(720000),
|
||||
production_year: Some(2024),
|
||||
premiere_date: None,
|
||||
community_rating: Some(8.5),
|
||||
official_rating: Some("PG-13".to_string()),
|
||||
primary_image_tag: Some("image-tag-123".to_string()),
|
||||
image_id: Some("image-tag-123".to_string()),
|
||||
backdrop_image_tags: Some(vec!["backdrop-1".to_string()]),
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -1457,6 +1550,81 @@ mod tests {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Test version mirroring the real `HybridRepository::get_items`
|
||||
/// downloads-only gate: when `include_catalog_browse()` is false, the
|
||||
/// offline result is authoritative and the server is NOT queried, even
|
||||
/// when the cache is empty. Otherwise falls through to the normal
|
||||
/// cache-first logic in `get_items`.
|
||||
async fn get_items_gated(&self, parent_id: &str) -> Result<SearchResult, RepoError> {
|
||||
if !crate::repository::offline::include_catalog_browse() {
|
||||
let items = self.offline.get_items(parent_id, None).await?;
|
||||
// Authoritative: return as-is, never touch the server.
|
||||
return Ok(items);
|
||||
}
|
||||
self.get_items(parent_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize tests that mutate the process-global INCLUDE_CATALOG_BROWSE
|
||||
/// flag, and always restore it to the default (true) afterwards.
|
||||
static GATE_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// UT-070: with the downloads-only gate off, an empty offline result is
|
||||
/// returned as-is and the server is NOT queried.
|
||||
///
|
||||
/// @req-test: UR-052 - Offline "downloaded only" filtering
|
||||
/// @req-test: DR-080 - Empty offline result is authoritative when gate off
|
||||
#[tokio::test]
|
||||
async fn test_get_items_gate_off_empty_does_not_query_server() {
|
||||
let _guard = GATE_TEST_LOCK.lock_safe();
|
||||
crate::repository::offline::set_include_catalog_browse(false);
|
||||
|
||||
// Server has items, cache is empty. Gate off ⇒ the server must be ignored.
|
||||
let repo = TestHybridRepo::new(vec![
|
||||
create_test_item("s-1", "Server 1"),
|
||||
create_test_item("s-2", "Server 2"),
|
||||
]);
|
||||
|
||||
let result = repo.get_items_gated("parent-123").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.items.len(),
|
||||
0,
|
||||
"empty offline result is authoritative when the gate is off"
|
||||
);
|
||||
assert_eq!(
|
||||
repo.online.get_query_count(),
|
||||
0,
|
||||
"server must NOT be queried when the gate is off"
|
||||
);
|
||||
|
||||
crate::repository::offline::set_include_catalog_browse(true);
|
||||
}
|
||||
|
||||
/// Guard the online path: with the gate ON and an empty cache, get_items
|
||||
/// still falls through to the server (unchanged behaviour).
|
||||
///
|
||||
/// @req-test: UR-052 - Offline "downloaded only" filtering
|
||||
/// @req-test: DR-080 - Gate on ⇒ empty cache still queries the server
|
||||
#[tokio::test]
|
||||
async fn test_get_items_gate_on_empty_falls_through_to_server() {
|
||||
let _guard = GATE_TEST_LOCK.lock_safe();
|
||||
crate::repository::offline::set_include_catalog_browse(true);
|
||||
|
||||
let repo = TestHybridRepo::new(vec![
|
||||
create_test_item("s-1", "Server 1"),
|
||||
create_test_item("s-2", "Server 2"),
|
||||
]);
|
||||
|
||||
let result = repo.get_items_gated("parent-123").await.unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2, "server result used on empty cache");
|
||||
assert_eq!(
|
||||
repo.online.get_query_count(),
|
||||
1,
|
||||
"server IS queried when the gate is on and the cache is empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test cache miss saves server data to cache for next time
|
||||
|
||||
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
|
||||
/// @req: JA-007 - Get playback info and stream URL
|
||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||
|
||||
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
|
||||
///
|
||||
/// Used when autoplay advances to the next episode while the app is playing a
|
||||
/// video in audio-only mode in the background: the backend needs the next
|
||||
/// episode's audio-only URL without any frontend round-trip. Online-only;
|
||||
/// offline/cache repositories return an error.
|
||||
///
|
||||
/// TRACES: UR-040 | JA-032
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError>;
|
||||
|
||||
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Offline repository - queries SQLite database for cached data
|
||||
//
|
||||
// TRACES: UR-002, UR-052 | DR-012, DR-013, DR-078
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -18,6 +20,8 @@ use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteSer
|
||||
/// the full greyed-out catalog. See `set_include_catalog_browse` and the
|
||||
/// `showServerCatalog` UI flag. Fixes the bug where offline library pages showed
|
||||
/// every server item regardless of the toggle.
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078
|
||||
static INCLUDE_CATALOG_BROWSE: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
/// Set whether offline `get_items` includes non-downloaded (synced-only) catalog
|
||||
@@ -28,7 +32,16 @@ pub fn set_include_catalog_browse(include: bool) {
|
||||
INCLUDE_CATALOG_BROWSE.store(include, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn include_catalog_browse() -> bool {
|
||||
/// Whether offline `get_items` currently includes the synced-but-not-downloaded
|
||||
/// catalog (the greyed-out browse view). Mirrors `set_include_catalog_browse`.
|
||||
///
|
||||
/// Exposed so the hybrid repo can tell "cache is cold, ask the server" from
|
||||
/// "user asked for downloads only and there are none here": when this is false,
|
||||
/// an empty offline `get_items` is authoritative and must not fall through to
|
||||
/// the server. See hybrid.rs `get_items`.
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078, DR-080
|
||||
pub fn include_catalog_browse() -> bool {
|
||||
INCLUDE_CATALOG_BROWSE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -55,10 +68,13 @@ impl OfflineRepository {
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let kind = crate::domain::kind_from_jellyfin(&item.item_type, item.is_folder);
|
||||
|
||||
MediaItem {
|
||||
id: item.id.clone(),
|
||||
name: item.name,
|
||||
item_type: item.item_type,
|
||||
kind,
|
||||
is_folder: item.is_folder,
|
||||
server_id: item.server_id,
|
||||
parent_id: item.parent_id,
|
||||
@@ -69,11 +85,13 @@ impl OfflineRepository {
|
||||
.as_ref()
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok()),
|
||||
runtime_ticks: item.runtime_ticks,
|
||||
duration_ms: item.runtime_ticks.map(crate::domain::ticks_to_ms),
|
||||
production_year: item.production_year,
|
||||
premiere_date: item.premiere_date,
|
||||
community_rating: item.community_rating,
|
||||
official_rating: item.official_rating,
|
||||
primary_image_tag: item.primary_image_tag,
|
||||
primary_image_tag: item.primary_image_tag.clone(),
|
||||
image_id: item.primary_image_tag,
|
||||
backdrop_image_tags: item.backdrop_image_tags,
|
||||
parent_backdrop_image_tags: item.parent_backdrop_image_tags,
|
||||
album_id: item.album_id,
|
||||
@@ -107,8 +125,10 @@ impl OfflineRepository {
|
||||
|
||||
self.db_service
|
||||
.query_optional(query, |row| {
|
||||
let playback_position_ticks: Option<i64> = row.get(0).ok();
|
||||
Ok(UserData {
|
||||
playback_position_ticks: row.get(0).ok(),
|
||||
playback_position_ticks,
|
||||
playback_position_ms: playback_position_ticks.map(crate::domain::ticks_to_ms),
|
||||
is_played: row.get::<_, Option<i32>>(1).ok().flatten().map(|v| v != 0),
|
||||
is_favorite: row.get::<_, Option<i32>>(2).ok().flatten().map(|v| v != 0),
|
||||
play_count: row.get(3).ok(),
|
||||
@@ -509,6 +529,303 @@ impl OfflineRepository {
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// SQL fragment: the set of item ids that are "on the device" — playable
|
||||
/// items with a completed download, plus containers (album/series/season)
|
||||
/// that have at least one downloaded child. This is the `get_items` CTE with
|
||||
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
|
||||
/// is authoritative regardless of the process-wide catalog-browse flag.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
const DOWNLOADED_ITEMS_CTE: &'static str = "
|
||||
WITH downloaded_items AS (
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
INNER JOIN downloads d ON i.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.item_type IN ('Audio', 'Movie', 'Episode')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT DISTINCT i.id
|
||||
FROM items i
|
||||
INNER JOIN items children ON (children.parent_id = i.id OR children.album_id = i.id OR children.season_id = i.id OR children.series_id = i.id)
|
||||
INNER JOIN downloads d ON children.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
)";
|
||||
|
||||
/// Downloaded-only browse: items under `parent_id` that are on the device.
|
||||
///
|
||||
/// Unlike [`MediaRepository::get_items`], this never includes the
|
||||
/// synced-but-not-downloaded catalog and never consults the process-wide
|
||||
/// `INCLUDE_CATALOG_BROWSE` flag — it is the dedicated Downloads surface.
|
||||
/// An empty result is authoritative ("nothing downloaded here"), so the
|
||||
/// hybrid repo must call this directly rather than racing the server.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083
|
||||
pub async fn get_downloaded_items(
|
||||
&self,
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let opts = options.unwrap_or_default();
|
||||
let limit = opts.limit.unwrap_or(10000);
|
||||
let start_index = opts.start_index.unwrap_or(0);
|
||||
|
||||
let type_filter = if let Some(include_item_types) = &opts.include_item_types {
|
||||
if !include_item_types.is_empty() {
|
||||
let types = include_item_types
|
||||
.iter()
|
||||
.map(|t| format!("'{}'", t.replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(" AND i.item_type IN ({})", types)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// When the parent is a LIBRARY, cached items carry no link back to it
|
||||
// (library_id/parent_id are NULL), so the `libraries` EXISTS clause below
|
||||
// matches every downloaded item on the server — both containers
|
||||
// (MusicAlbum/Series/…) AND their leaves (Audio/Episode). Listing the
|
||||
// leaves alongside the containers is the "I see individual songs, not
|
||||
// albums" bug: a library landing page must show only *top-level* items.
|
||||
// So at the library level we exclude any leaf whose own container
|
||||
// (album/season/series/parent) is itself present in `downloaded_items` —
|
||||
// that container represents it in the grid. Items with no downloaded
|
||||
// container (e.g. a downloaded Movie, or a stray track whose album isn't
|
||||
// cached) still surface. This mirrors the online music library, which
|
||||
// routes to a dedicated albums view. See [[offline-libraries-never-cached]].
|
||||
let sql = format!(
|
||||
"{cte}
|
||||
SELECT i.id, i.name, i.item_type, i.server_id, i.parent_id, i.library_id, i.overview, i.genres,
|
||||
i.runtime_ticks, i.production_year, i.community_rating, i.official_rating,
|
||||
i.primary_image_tag, i.album_id, i.album_name, i.album_artist, i.artists,
|
||||
i.index_number, i.series_id, i.series_name, i.season_id, i.season_name,
|
||||
i.parent_index_number, i.is_folder, i.premiere_date
|
||||
FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = ?
|
||||
AND (
|
||||
i.parent_id = ? OR i.album_id = ? OR i.season_id = ? OR i.series_id = ?
|
||||
OR (
|
||||
EXISTS (
|
||||
SELECT 1 FROM libraries l
|
||||
WHERE l.id = ? AND l.server_id = i.server_id
|
||||
)
|
||||
-- Top-level only: hide leaves whose container is downloaded.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM downloaded_items parent
|
||||
WHERE parent.id = i.album_id
|
||||
OR parent.id = i.season_id
|
||||
OR parent.id = i.series_id
|
||||
OR parent.id = i.parent_id
|
||||
)
|
||||
)
|
||||
){type_filter}
|
||||
ORDER BY i.sort_name ASC, i.name ASC
|
||||
LIMIT {limit} OFFSET {start_index}",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
);
|
||||
|
||||
let query = Query::with_params(
|
||||
sql,
|
||||
vec![
|
||||
QueryParam::String(self.server_id.clone()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
QueryParam::String(parent_id.to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
let cached_items: Vec<CachedItem> = self
|
||||
.db_service
|
||||
.query_many(query, row_to_cached_item)
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for cached in cached_items {
|
||||
let user_data = self.get_user_data(&cached.id).await;
|
||||
items.push(Self::cached_item_to_media_item(cached, user_data));
|
||||
}
|
||||
|
||||
let total_record_count = items.len();
|
||||
Ok(SearchResult {
|
||||
items,
|
||||
total_record_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Libraries that contain at least one downloaded item. Libraries with
|
||||
/// nothing on the device are omitted, so the Downloaded surface only lists
|
||||
/// libraries the user actually has offline content in.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082
|
||||
pub async fn get_downloaded_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
||||
// A downloaded item links back to its library only indirectly (the
|
||||
// cache leaves library_id NULL — see [[offline-libraries-never-cached]]).
|
||||
// We match a library by collection_type ↔ item_type instead: any
|
||||
// completed download of a given media kind qualifies that library.
|
||||
let query = Query::with_params(
|
||||
&format!(
|
||||
"{cte}
|
||||
SELECT l.id, l.name, l.collection_type, l.image_tag
|
||||
FROM libraries l
|
||||
WHERE l.server_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM items i
|
||||
INNER JOIN downloaded_items di ON i.id = di.id
|
||||
WHERE i.server_id = l.server_id
|
||||
AND (
|
||||
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
|
||||
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
|
||||
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
|
||||
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
|
||||
)
|
||||
)
|
||||
ORDER BY l.sort_order ASC, l.name ASC",
|
||||
cte = Self::DOWNLOADED_ITEMS_CTE,
|
||||
),
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
|
||||
self.db_service
|
||||
.query_many(query, |row| {
|
||||
Ok(Library {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
collection_type: row
|
||||
.get::<_, Option<String>>(2)?
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
image_tag: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })
|
||||
}
|
||||
|
||||
/// On-disk bytes for downloaded content, for the disk-usage display.
|
||||
///
|
||||
/// Returns one entry per *container or leaf* that appears in the Downloaded
|
||||
/// browse: a leaf's own `file_size`, a container's summed downloaded
|
||||
/// descendants — plus the device total and item (leaf) count. This is pure
|
||||
/// aggregation over `downloads.file_size`, not new tracking.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
pub async fn get_download_disk_usage(&self) -> Result<DownloadDiskUsage, RepoError> {
|
||||
// Per-leaf sizes (completed playable downloads only).
|
||||
let leaf_query = Query::with_params(
|
||||
"SELECT d.item_id, COALESCE(d.file_size, 0)
|
||||
FROM downloads d
|
||||
INNER JOIN items i ON i.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND i.server_id = ?
|
||||
AND i.item_type IN ('Audio', 'Movie', 'Episode')",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let leaves: Vec<(String, i64)> = self
|
||||
.db_service
|
||||
.query_many(leaf_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
// Container subtotals: sum each container's downloaded descendants.
|
||||
let container_query = Query::with_params(
|
||||
"SELECT c.id, COALESCE(SUM(d.file_size), 0)
|
||||
FROM items c
|
||||
INNER JOIN items children
|
||||
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
|
||||
INNER JOIN downloads d ON children.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND c.server_id = ?
|
||||
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
GROUP BY c.id",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let containers: Vec<(String, i64)> = self
|
||||
.db_service
|
||||
.query_many(container_query, |row| Ok((row.get(0)?, row.get(1)?)))
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
// Partiality per container: a container is "partial" when it has cached
|
||||
// descendants that are NOT downloaded. We compare downloaded-descendant
|
||||
// count against total-cached-descendant count (the offline cache holds
|
||||
// the synced full catalog, so this is meaningful).
|
||||
//
|
||||
// Perf: restrict `c` to containers that actually have a completed
|
||||
// download *first* (the CTE), so the OR-based self-join runs over that
|
||||
// handful of rows instead of the entire synced catalog. Without this the
|
||||
// join is an unindexable O(items²) scan and the Downloaded page hangs on
|
||||
// a large library ("Loading your downloads…" forever).
|
||||
let partial_query = Query::with_params(
|
||||
"WITH downloaded_containers AS (
|
||||
SELECT DISTINCT c.id
|
||||
FROM items c
|
||||
INNER JOIN items children
|
||||
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
|
||||
INNER JOIN downloads d ON children.id = d.item_id
|
||||
WHERE d.status = 'completed'
|
||||
AND c.server_id = ?
|
||||
AND c.item_type IN ('MusicAlbum', 'Series', 'Season', 'BoxSet', 'Folder', 'CollectionFolder')
|
||||
)
|
||||
SELECT c.id,
|
||||
COUNT(children.id) AS total_children,
|
||||
SUM(CASE WHEN d.status = 'completed' THEN 1 ELSE 0 END) AS downloaded_children
|
||||
FROM items c
|
||||
INNER JOIN downloaded_containers dc ON dc.id = c.id
|
||||
INNER JOIN items children
|
||||
ON (children.parent_id = c.id OR children.album_id = c.id OR children.season_id = c.id OR children.series_id = c.id)
|
||||
LEFT JOIN downloads d ON children.id = d.item_id AND d.status = 'completed'
|
||||
WHERE children.item_type IN ('Audio', 'Movie', 'Episode', 'Season')
|
||||
GROUP BY c.id",
|
||||
vec![QueryParam::String(self.server_id.clone())],
|
||||
);
|
||||
let partial_rows: Vec<(String, i64, i64)> = self
|
||||
.db_service
|
||||
.query_many(partial_query, |row| {
|
||||
Ok((
|
||||
row.get(0)?,
|
||||
row.get(1)?,
|
||||
row.get::<_, Option<i64>>(2)?.unwrap_or(0),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RepoError::Database { message: e })?;
|
||||
|
||||
let mut partial_containers = std::collections::HashMap::new();
|
||||
for (id, total, downloaded) in partial_rows {
|
||||
// Only record containers that actually have a download (they appear
|
||||
// in the browse); mark partial when some cached child is missing.
|
||||
if downloaded > 0 && downloaded < total {
|
||||
partial_containers.insert(id, true);
|
||||
}
|
||||
}
|
||||
|
||||
let item_count = leaves.len() as u32;
|
||||
let device_total_bytes: i64 = leaves.iter().map(|(_, b)| *b).sum();
|
||||
|
||||
let mut sizes = std::collections::HashMap::new();
|
||||
for (id, bytes) in leaves.into_iter().chain(containers.into_iter()) {
|
||||
// A container id can never collide with a leaf id, so a plain insert
|
||||
// is fine; use entry to be defensive against duplicate rows.
|
||||
*sizes.entry(id).or_insert(0) += bytes;
|
||||
}
|
||||
|
||||
Ok(DownloadDiskUsage {
|
||||
sizes,
|
||||
partial_containers,
|
||||
device_total_bytes,
|
||||
item_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cache playlist items from server into local database
|
||||
/// Called by HybridRepository after fetching from online
|
||||
pub async fn save_playlist_items_to_cache(
|
||||
@@ -1201,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
_item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
// Audio-only transcode requires the server; offline downloads play locally.
|
||||
Err(RepoError::Offline)
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV is inherently online-only.
|
||||
Err(RepoError::Offline)
|
||||
@@ -1326,6 +1654,7 @@ impl MediaRepository for OfflineRepository {
|
||||
id: person_data.0,
|
||||
name: person_data.1,
|
||||
item_type: "Person".to_string(),
|
||||
kind: crate::domain::MediaKind::Person,
|
||||
is_folder: false,
|
||||
server_id: self.server_id.clone(),
|
||||
parent_id: None,
|
||||
@@ -1333,11 +1662,13 @@ impl MediaRepository for OfflineRepository {
|
||||
overview: person_data.2,
|
||||
genres: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
production_year: None,
|
||||
premiere_date: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
primary_image_tag: person_data.3,
|
||||
primary_image_tag: person_data.3.clone(),
|
||||
image_id: person_data.3,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -1785,7 +2116,8 @@ mod tests {
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
status TEXT NOT NULL,
|
||||
file_size INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE libraries (
|
||||
@@ -1826,6 +2158,7 @@ mod tests {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
kind: crate::domain::MediaKind::Track,
|
||||
is_folder: false,
|
||||
server_id: "test-server".to_string(),
|
||||
parent_id: parent_id.map(|s| s.to_string()),
|
||||
@@ -1833,11 +2166,13 @@ mod tests {
|
||||
overview: None,
|
||||
genres: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
production_year: None,
|
||||
premiere_date: None,
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -2052,6 +2387,13 @@ mod tests {
|
||||
/// downloaded media — not the whole synced catalog. With it on, the full
|
||||
/// (synced-but-not-downloaded) catalog is revealed. Fixes the bug where
|
||||
/// offline library pages showed every server item regardless of the toggle.
|
||||
///
|
||||
/// This is also the backend half of the end-to-end offline-listing scenario
|
||||
/// IT-016: toggle off ⇒ downloaded media only; toggle on ⇒ the cached server
|
||||
/// catalog is additionally revealed (greyed-out in the UI, distinguished by
|
||||
/// the absence of a `downloads` row — see `MediaCard.isServerOnly`).
|
||||
///
|
||||
/// TRACES: UR-052 | DR-078 | UT-067, IT-016
|
||||
#[tokio::test]
|
||||
async fn test_get_items_toggle_gates_synced_catalog() {
|
||||
use crate::storage::db_service::DatabaseService;
|
||||
@@ -2289,6 +2631,291 @@ mod tests {
|
||||
repo.save_to_cache("library-1", &items).await.unwrap();
|
||||
}
|
||||
|
||||
/// Insert a fully-formed item row of a given type (bypasses save_to_cache's
|
||||
/// stub-parent machinery so containers/leaves can be linked precisely).
|
||||
async fn insert_item(
|
||||
db: &Arc<RusqliteService>,
|
||||
id: &str,
|
||||
item_type: &str,
|
||||
album_id: Option<&str>,
|
||||
series_id: Option<&str>,
|
||||
season_id: Option<&str>,
|
||||
) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO items (id, server_id, name, item_type, album_id, series_id, season_id, synced_at)
|
||||
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, ?6, '2024-01-01')",
|
||||
vec![
|
||||
QueryParam::String(id.to_string()),
|
||||
QueryParam::String(format!("Name {id}")),
|
||||
QueryParam::String(item_type.to_string()),
|
||||
album_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
series_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
season_id.map(|s| QueryParam::String(s.to_string())).unwrap_or(QueryParam::Null),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
||||
vec![
|
||||
QueryParam::String(item_id.to_string()),
|
||||
QueryParam::Int64(file_size),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn seed_library(db: &Arc<RusqliteService>, id: &str, collection_type: &str) {
|
||||
db.execute(Query::with_params(
|
||||
"INSERT INTO libraries (id, server_id, name, collection_type, sort_order)
|
||||
VALUES (?1, 'test-server', ?2, ?3, 0)",
|
||||
vec![
|
||||
QueryParam::String(id.to_string()),
|
||||
QueryParam::String(format!("Lib {id}")),
|
||||
QueryParam::String(collection_type.to_string()),
|
||||
],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn make_repo(db: &Arc<RusqliteService>) -> OfflineRepository {
|
||||
OfflineRepository::new(
|
||||
db.clone(),
|
||||
"test-server".to_string(),
|
||||
"test-user".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
||||
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083 | UT-072
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_returns_leaf_and_container() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
// track-1 downloaded; track-2 is NOT downloaded.
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
// Browsing the album shows only the downloaded track.
|
||||
let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
|
||||
let ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["track-1"], "only the downloaded track is listed");
|
||||
}
|
||||
|
||||
/// Regression: browsing a downloaded *library* (top level) lists containers,
|
||||
/// not their leaves — a music library shows the album, not the individual
|
||||
/// downloaded songs. The leaf is still reachable by drilling into the album.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083 | UT-076
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_library_lists_albums_not_tracks() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
// Tracks link to the album via album_id (parent_id NULL in the cache).
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
seed_completed_download(&db, "track-2", 1000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
// Library level: only the album shows, not the two tracks.
|
||||
let at_library = repo.get_downloaded_items("music-lib", None).await.unwrap();
|
||||
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["album-1"],
|
||||
"library browse lists the album container, not its tracks"
|
||||
);
|
||||
|
||||
// Drilling into the album still returns the downloaded tracks.
|
||||
let in_album = repo.get_downloaded_items("album-1", None).await.unwrap();
|
||||
let mut track_ids: Vec<&str> = in_album.items.iter().map(|i| i.id.as_str()).collect();
|
||||
track_ids.sort();
|
||||
assert_eq!(track_ids, vec!["track-1", "track-2"]);
|
||||
}
|
||||
|
||||
/// Regression: a downloaded TV library lists the Series, not its Seasons or
|
||||
/// Episodes — the same "individual songs" bug seen for music, for TV. The
|
||||
/// season and episode are still reachable by drilling into the series.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083 | UT-077
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_library_lists_series_not_episodes() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "tv-lib", "tvshows").await;
|
||||
insert_item(&db, "series-1", "Series", None, None, None).await;
|
||||
// Season links to its series; episode links to both season and series.
|
||||
insert_item(&db, "season-1", "Season", None, Some("series-1"), None).await;
|
||||
insert_item(
|
||||
&db,
|
||||
"ep-1",
|
||||
"Episode",
|
||||
None,
|
||||
Some("series-1"),
|
||||
Some("season-1"),
|
||||
)
|
||||
.await;
|
||||
seed_completed_download(&db, "ep-1", 4000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
|
||||
// Library level: only the series shows.
|
||||
let at_library = repo.get_downloaded_items("tv-lib", None).await.unwrap();
|
||||
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["series-1"],
|
||||
"TV library browse lists the series, not seasons/episodes"
|
||||
);
|
||||
|
||||
// Drilling into the series returns its season; into the season, the episode.
|
||||
let in_series = repo.get_downloaded_items("series-1", None).await.unwrap();
|
||||
assert!(
|
||||
in_series.items.iter().any(|i| i.id == "season-1"),
|
||||
"series drill returns the season"
|
||||
);
|
||||
let in_season = repo.get_downloaded_items("season-1", None).await.unwrap();
|
||||
assert!(
|
||||
in_season.items.iter().any(|i| i.id == "ep-1"),
|
||||
"season drill returns the episode"
|
||||
);
|
||||
}
|
||||
|
||||
/// A downloaded leaf with no cached container (e.g. a Movie, or a track whose
|
||||
/// album isn't in the cache) still surfaces at the library level.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082, DR-083 | UT-078
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_library_keeps_orphan_leaves() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
insert_item(&db, "movie-1", "Movie", None, None, None).await;
|
||||
seed_completed_download(&db, "movie-1", 5000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let at_library = repo.get_downloaded_items("movie-lib", None).await.unwrap();
|
||||
let ids: Vec<&str> = at_library.items.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["movie-1"],
|
||||
"a downloaded movie with no container shows"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: an empty downloaded-only browse is authoritative — no rows, no error,
|
||||
/// regardless of the catalog-browse flag (which the DR-080 fallthrough uses).
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082 | UT-073
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_items_empty_is_authoritative() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
// Nothing downloaded, and the catalog-browse flag is ON (online default).
|
||||
set_include_catalog_browse(true);
|
||||
let repo = make_repo(&db);
|
||||
|
||||
let result = repo.get_downloaded_items("album-1", None).await.unwrap();
|
||||
assert!(
|
||||
result.items.is_empty(),
|
||||
"empty downloaded browse returns no items even with catalog-browse on"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: only libraries with downloaded content are listed; an empty one is omitted.
|
||||
///
|
||||
/// TRACES: UR-055 | DR-082 | UT-074
|
||||
#[tokio::test]
|
||||
async fn test_get_downloaded_libraries_omits_empty() {
|
||||
let db = create_test_db();
|
||||
seed_library(&db, "music-lib", "music").await;
|
||||
seed_library(&db, "movie-lib", "movies").await;
|
||||
insert_item(&db, "track-1", "Audio", None, None, None).await;
|
||||
seed_completed_download(&db, "track-1", 500).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let libs = repo.get_downloaded_libraries().await.unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["music-lib"],
|
||||
"movie library with no downloads omitted"
|
||||
);
|
||||
}
|
||||
|
||||
/// UT: disk usage reports a leaf's own size, a container's summed descendants,
|
||||
/// and reconciles the device total with the sum of leaves.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085 | UT-075
|
||||
#[tokio::test]
|
||||
async fn test_download_disk_usage_aggregates_containers() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
seed_completed_download(&db, "track-2", 2000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let usage = repo.get_download_disk_usage().await.unwrap();
|
||||
|
||||
assert_eq!(usage.item_count, 2, "two leaf downloads");
|
||||
assert_eq!(
|
||||
usage.device_total_bytes, 3000,
|
||||
"device total is the leaf sum"
|
||||
);
|
||||
assert_eq!(usage.sizes.get("track-1"), Some(&1000));
|
||||
assert_eq!(
|
||||
usage.sizes.get("album-1"),
|
||||
Some(&3000),
|
||||
"container = sum of children"
|
||||
);
|
||||
// Both children downloaded ⇒ album is NOT partial.
|
||||
assert_eq!(
|
||||
usage.partial_containers.get("album-1"),
|
||||
None,
|
||||
"fully downloaded album is not partial"
|
||||
);
|
||||
// Device total reconciles with the sum of the listed leaves.
|
||||
let leaf_sum: i64 = ["track-1", "track-2"]
|
||||
.iter()
|
||||
.map(|id| usage.sizes[*id])
|
||||
.sum();
|
||||
assert_eq!(leaf_sum, usage.device_total_bytes);
|
||||
}
|
||||
|
||||
/// UT: a container with a downloaded child AND a non-downloaded cached child
|
||||
/// is flagged partial. TRACES: UR-055 | DR-083 | UT-051
|
||||
#[tokio::test]
|
||||
async fn test_download_disk_usage_flags_partial_container() {
|
||||
let db = create_test_db();
|
||||
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
|
||||
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
|
||||
insert_item(&db, "track-2", "Audio", Some("album-1"), None, None).await;
|
||||
// Only track-1 downloaded; track-2 is cached but not downloaded.
|
||||
seed_completed_download(&db, "track-1", 1000).await;
|
||||
|
||||
let repo = make_repo(&db);
|
||||
let usage = repo.get_download_disk_usage().await.unwrap();
|
||||
assert_eq!(
|
||||
usage.partial_containers.get("album-1"),
|
||||
Some(&true),
|
||||
"album with a missing child is partial"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_playlist_create_empty() {
|
||||
let db_service = create_test_db();
|
||||
|
||||
@@ -450,7 +450,7 @@ impl OnlineRepository {
|
||||
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
||||
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
||||
/// decodable and supports mid-stream `StartTimeTicks`.
|
||||
pub async fn get_audio_only_stream_url_for_video(
|
||||
pub async fn build_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
@@ -619,10 +619,13 @@ impl JellyfinItem {
|
||||
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
|
||||
let backdrop_tags = self.backdrop_image_tags;
|
||||
|
||||
let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);
|
||||
|
||||
MediaItem {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
item_type: self.item_type,
|
||||
kind,
|
||||
is_folder: self.is_folder,
|
||||
server_id,
|
||||
parent_id: self.parent_id,
|
||||
@@ -634,7 +637,9 @@ impl JellyfinItem {
|
||||
community_rating: self.community_rating,
|
||||
official_rating: self.official_rating,
|
||||
runtime_ticks: self.run_time_ticks,
|
||||
primary_image_tag: primary_tag,
|
||||
duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
|
||||
primary_image_tag: primary_tag.clone(),
|
||||
image_id: primary_tag,
|
||||
backdrop_image_tags: backdrop_tags,
|
||||
parent_backdrop_image_tags: self.parent_backdrop_image_tags,
|
||||
album_id: self.album_id,
|
||||
@@ -653,6 +658,7 @@ impl JellyfinItem {
|
||||
streams
|
||||
.into_iter()
|
||||
.map(|s| crate::repository::types::MediaStream {
|
||||
kind: crate::domain::stream_kind_from_jellyfin(&s.stream_type),
|
||||
stream_type: s.stream_type,
|
||||
codec: s.codec,
|
||||
language: s.language,
|
||||
@@ -923,6 +929,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown Album".to_string()),
|
||||
item_type: "MusicAlbum".to_string(),
|
||||
kind: crate::domain::MediaKind::Album,
|
||||
is_folder: true,
|
||||
server_id: first_track.server_id.clone(),
|
||||
parent_id: None,
|
||||
@@ -934,7 +941,9 @@ impl MediaRepository for OnlineRepository {
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: first_track.primary_image_tag.clone(),
|
||||
image_id: first_track.primary_image_tag.clone(),
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -1346,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
media_source_id: Option<&str>,
|
||||
start_time_seconds: Option<f64>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<String, RepoError> {
|
||||
self.build_audio_only_stream_url_for_video(
|
||||
item_id,
|
||||
media_source_id,
|
||||
start_time_seconds,
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
||||
// type "TvChannel" — playable via open_live_stream.
|
||||
|
||||
@@ -42,8 +42,16 @@ pub struct Library {
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserData {
|
||||
/// Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
/// `playback_position_ms`; dual-carried while the frontend migrates
|
||||
/// (docs/specs/frontend-domain-model.md). New code should read the ms field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub playback_position_ticks: Option<i64>,
|
||||
/// Resume position in milliseconds — the neutral replacement for
|
||||
/// `playback_position_ticks`. Populated from ticks by the mapping; the
|
||||
/// frontend never divides ticks itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub playback_position_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_played: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -97,13 +105,24 @@ pub struct Person {
|
||||
}
|
||||
|
||||
/// Media item
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
|
||||
///
|
||||
/// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
|
||||
/// is the neutral replacement. This field stays while the frontend migrates
|
||||
/// off it, then is removed in a later phase. New Rust code should read
|
||||
/// `kind`, not this.
|
||||
#[serde(rename = "type")]
|
||||
pub item_type: String,
|
||||
/// Provider-neutral classification — the replacement for `item_type`.
|
||||
/// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
|
||||
/// construction sites that have not been migrated yet.
|
||||
#[serde(default)]
|
||||
pub kind: crate::domain::MediaKind,
|
||||
/// Whether this item is a folder/container (vs a playable leaf). Used to
|
||||
/// decide whether a channel item drills into a list or plays directly.
|
||||
#[serde(default)]
|
||||
@@ -127,11 +146,25 @@ pub struct MediaItem {
|
||||
pub community_rating: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub official_rating: Option<String>,
|
||||
/// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
|
||||
/// `duration_ms`; dual-carried while the frontend migrates
|
||||
/// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "runTimeTicks")]
|
||||
pub runtime_ticks: Option<i64>,
|
||||
/// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
|
||||
/// Ticks never reach the frontend; this does.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<i64>,
|
||||
/// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
|
||||
/// dual-carried while the frontend migrates. New code should read `image_id`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub primary_image_tag: Option<String>,
|
||||
/// Neutral image identifier the frontend resolves to a URL via the image
|
||||
/// command — the replacement for `primary_image_tag`. Same value today
|
||||
/// (Jellyfin's tag is the id); the rename removes the provider term.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backdrop_image_tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -172,8 +205,13 @@ pub struct MediaItem {
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaStream {
|
||||
/// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
|
||||
/// replaced by `kind`; dual-carried while the frontend migrates.
|
||||
#[serde(rename = "type")]
|
||||
pub stream_type: String,
|
||||
/// Provider-neutral stream classification — replaces `stream_type`.
|
||||
#[serde(default)]
|
||||
pub kind: crate::domain::StreamKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub codec: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -212,6 +250,28 @@ pub struct SearchResult {
|
||||
pub total_record_count: usize,
|
||||
}
|
||||
|
||||
/// On-disk usage of downloaded content, for the Downloads surface.
|
||||
///
|
||||
/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
|
||||
/// own file size, a container's summed downloaded descendants. `device_total_bytes`
|
||||
/// and `item_count` are the headline figures for the Downloaded surface top bar.
|
||||
///
|
||||
/// TRACES: UR-056 | DR-085
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadDiskUsage {
|
||||
/// item id → bytes on disk (leaf's own size, or a container's subtotal).
|
||||
pub sizes: std::collections::HashMap<String, i64>,
|
||||
/// Container id → true when it is only *partially* downloaded (has cached
|
||||
/// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
|
||||
/// the Downloaded surface badge partial vs. full containers.
|
||||
pub partial_containers: std::collections::HashMap<String, bool>,
|
||||
/// Sum of all downloaded leaf sizes — the device total.
|
||||
pub device_total_bytes: i64,
|
||||
/// Number of downloaded leaf items (not containers).
|
||||
pub item_count: u32,
|
||||
}
|
||||
|
||||
/// Options for querying items
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -503,6 +563,7 @@ mod tests {
|
||||
id: "1".to_string(),
|
||||
name: "Test".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
kind: crate::domain::MediaKind::Track,
|
||||
is_folder: false,
|
||||
server_id: "server1".to_string(),
|
||||
parent_id: None,
|
||||
@@ -514,7 +575,9 @@ mod tests {
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -653,6 +716,7 @@ mod tests {
|
||||
id: "track1".to_string(),
|
||||
name: "Test Track".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
kind: crate::domain::MediaKind::Track,
|
||||
is_folder: false,
|
||||
server_id: "server1".to_string(),
|
||||
parent_id: None,
|
||||
@@ -664,7 +728,9 @@ mod tests {
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
@@ -714,6 +780,7 @@ mod tests {
|
||||
id: "1".to_string(),
|
||||
name: "Track".to_string(),
|
||||
item_type: "Audio".to_string(),
|
||||
kind: crate::domain::MediaKind::Track,
|
||||
is_folder: false,
|
||||
server_id: "s1".to_string(),
|
||||
parent_id: None,
|
||||
@@ -725,7 +792,9 @@ mod tests {
|
||||
community_rating: None,
|
||||
official_rating: None,
|
||||
runtime_ticks: None,
|
||||
duration_ms: None,
|
||||
primary_image_tag: None,
|
||||
image_id: None,
|
||||
backdrop_image_tags: None,
|
||||
parent_backdrop_image_tags: None,
|
||||
album_id: None,
|
||||
|
||||
+180
-1
@@ -1,4 +1,4 @@
|
||||
//! TRACES: UR-023, UR-031, UR-032, UR-033 | DR-034, DR-035, DR-036, DR-048
|
||||
//! TRACES: UR-023, UR-027, UR-031, UR-032, UR-033 | DR-030, DR-034, DR-035, DR-036, DR-048, IR-020
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -26,6 +26,67 @@ impl VolumeLevel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre frequencies (Hz) of the fixed 10-band ISO equalizer. The band count
|
||||
/// and layout are a property of the audio engine, not the UI — presets and the
|
||||
/// MPV filter are defined against these bands. See docs/specs/audio-equalizer.md.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030, IR-020
|
||||
pub const EQ_BANDS: [f32; 10] = [
|
||||
31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
|
||||
];
|
||||
/// Minimum per-band gain in dB.
|
||||
pub const EQ_GAIN_MIN: f32 = -12.0;
|
||||
/// Maximum per-band gain in dB.
|
||||
pub const EQ_GAIN_MAX: f32 = 12.0;
|
||||
|
||||
/// Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
||||
/// layout above (a domain concept), not a mere label — the curve numbers live
|
||||
/// in Rust so the frontend never encodes the taxonomy.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum EqPreset {
|
||||
Flat,
|
||||
Rock,
|
||||
Pop,
|
||||
Jazz,
|
||||
Classical,
|
||||
BassBoost,
|
||||
TrebleBoost,
|
||||
Vocal,
|
||||
}
|
||||
|
||||
impl EqPreset {
|
||||
/// All presets, for enumerating the curve table across the IPC boundary.
|
||||
pub const ALL: [EqPreset; 8] = [
|
||||
EqPreset::Flat,
|
||||
EqPreset::Rock,
|
||||
EqPreset::Pop,
|
||||
EqPreset::Jazz,
|
||||
EqPreset::Classical,
|
||||
EqPreset::BassBoost,
|
||||
EqPreset::TrebleBoost,
|
||||
EqPreset::Vocal,
|
||||
];
|
||||
|
||||
/// The 10-band gain curve (dB) for this preset, one entry per [`EQ_BANDS`].
|
||||
/// Curves are conservative (within ±8 dB) so presets stack safely with the
|
||||
/// player volume. Bands: 31 62 125 250 500 1k 2k 4k 8k 16k.
|
||||
pub fn gains(&self) -> [f32; 10] {
|
||||
match self {
|
||||
EqPreset::Flat => [0.0; 10],
|
||||
EqPreset::Rock => [5.0, 4.0, 3.0, 1.0, -1.0, -1.0, 1.0, 3.0, 4.0, 5.0],
|
||||
EqPreset::Pop => [-1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0, -1.0],
|
||||
EqPreset::Jazz => [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.0, 2.0, 3.0],
|
||||
EqPreset::Classical => [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0],
|
||||
EqPreset::BassBoost => [7.0, 6.0, 5.0, 3.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
EqPreset::TrebleBoost => [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 3.0, 5.0, 6.0, 7.0],
|
||||
EqPreset::Vocal => [-2.0, -1.0, 0.0, 2.0, 4.0, 5.0, 4.0, 2.0, 0.0, -1.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Audio playback settings
|
||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -38,6 +99,18 @@ pub struct AudioSettings {
|
||||
pub normalize_volume: bool,
|
||||
/// Target volume level for normalization
|
||||
pub volume_level: VolumeLevel,
|
||||
/// Enable the graphic equalizer. When false, no EQ filter is applied.
|
||||
#[serde(default)]
|
||||
pub equalizer_enabled: bool,
|
||||
/// Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
|
||||
/// clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
|
||||
#[serde(default = "default_eq_bands")]
|
||||
pub equalizer_bands: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Flat 10-band curve — the default equalizer state.
|
||||
fn default_eq_bands() -> Vec<f32> {
|
||||
vec![0.0; EQ_BANDS.len()]
|
||||
}
|
||||
|
||||
impl Default for AudioSettings {
|
||||
@@ -47,6 +120,8 @@ impl Default for AudioSettings {
|
||||
gapless_playback: true,
|
||||
normalize_volume: false,
|
||||
volume_level: VolumeLevel::Normal,
|
||||
equalizer_enabled: false,
|
||||
equalizer_bands: default_eq_bands(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +132,20 @@ impl AudioSettings {
|
||||
self.crossfade_duration = self.crossfade_duration.clamp(0.0, 12.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Normalise the equalizer band vector to exactly [`EQ_BANDS`]`.len()`
|
||||
/// entries (pad with 0 dB / truncate) and clamp each gain to the valid
|
||||
/// range. Guards against malformed persisted or IPC input.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030
|
||||
pub fn with_equalizer_normalised(mut self) -> Self {
|
||||
let n = EQ_BANDS.len();
|
||||
self.equalizer_bands.resize(n, 0.0);
|
||||
for g in &mut self.equalizer_bands {
|
||||
*g = g.clamp(EQ_GAIN_MIN, EQ_GAIN_MAX);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Video playback settings
|
||||
@@ -101,6 +190,95 @@ mod tests {
|
||||
assert!(settings.gapless_playback);
|
||||
assert!(!settings.normalize_volume);
|
||||
assert_eq!(settings.volume_level, VolumeLevel::Normal);
|
||||
// Equalizer defaults: disabled and flat.
|
||||
assert!(!settings.equalizer_enabled);
|
||||
assert_eq!(settings.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||
}
|
||||
|
||||
/// EQ presets each return one gain per band; Flat is all zeros.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-079
|
||||
#[test]
|
||||
fn test_eq_preset_curves() {
|
||||
for preset in EqPreset::ALL {
|
||||
assert_eq!(
|
||||
preset.gains().len(),
|
||||
EQ_BANDS.len(),
|
||||
"preset {:?} must have one gain per band",
|
||||
preset
|
||||
);
|
||||
// Every preset stays within the advertised gain range.
|
||||
for g in preset.gains() {
|
||||
assert!(
|
||||
(EQ_GAIN_MIN..=EQ_GAIN_MAX).contains(&g),
|
||||
"preset {:?} gain {} out of range",
|
||||
preset,
|
||||
g
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(EqPreset::Flat.gains(), [0.0; 10]);
|
||||
// Bass boost lifts the low bands and leaves the top flat.
|
||||
let bass = EqPreset::BassBoost.gains();
|
||||
assert!(bass[0] > 0.0 && bass[9] == 0.0);
|
||||
}
|
||||
|
||||
/// `with_equalizer_normalised` clamps out-of-range gains and forces the
|
||||
/// band vector to exactly EQ_BANDS.len() (pad short, truncate long).
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-080
|
||||
#[test]
|
||||
fn test_eq_normalisation() {
|
||||
// Out-of-range gains are clamped.
|
||||
let s = AudioSettings {
|
||||
equalizer_bands: vec![100.0, -100.0, 3.0],
|
||||
..Default::default()
|
||||
}
|
||||
.with_equalizer_normalised();
|
||||
assert_eq!(s.equalizer_bands.len(), EQ_BANDS.len());
|
||||
assert_eq!(s.equalizer_bands[0], EQ_GAIN_MAX);
|
||||
assert_eq!(s.equalizer_bands[1], EQ_GAIN_MIN);
|
||||
assert_eq!(s.equalizer_bands[2], 3.0);
|
||||
// Short vector padded with 0 dB.
|
||||
assert_eq!(s.equalizer_bands[9], 0.0);
|
||||
|
||||
// Over-long vector truncated.
|
||||
let long = AudioSettings {
|
||||
equalizer_bands: vec![1.0; 20],
|
||||
..Default::default()
|
||||
}
|
||||
.with_equalizer_normalised();
|
||||
assert_eq!(long.equalizer_bands.len(), EQ_BANDS.len());
|
||||
}
|
||||
|
||||
/// Old persisted JSON without the EQ fields loads as disabled + flat.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-081
|
||||
#[test]
|
||||
fn test_audio_settings_eq_backward_compat() {
|
||||
let json = r#"{"crossfadeDuration":0.0,"gaplessPlayback":true,"normalizeVolume":false,"volumeLevel":"normal"}"#;
|
||||
let parsed: AudioSettings = serde_json::from_str(json).unwrap();
|
||||
assert!(!parsed.equalizer_enabled);
|
||||
assert_eq!(parsed.equalizer_bands, vec![0.0; EQ_BANDS.len()]);
|
||||
}
|
||||
|
||||
/// EQ fields serialize as camelCase and round-trip.
|
||||
///
|
||||
/// TRACES: UR-027 | DR-030 | UT-082
|
||||
#[test]
|
||||
fn test_audio_settings_eq_serialization() {
|
||||
let settings = AudioSettings {
|
||||
equalizer_enabled: true,
|
||||
equalizer_bands: EqPreset::Rock.gains().to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
assert!(json.contains("\"equalizerEnabled\":true"));
|
||||
assert!(json.contains("\"equalizerBands\":"));
|
||||
|
||||
let parsed: AudioSettings = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.equalizer_enabled);
|
||||
assert_eq!(parsed.equalizer_bands, EqPreset::Rock.gains().to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -134,6 +312,7 @@ mod tests {
|
||||
gapless_playback: true,
|
||||
normalize_volume: true,
|
||||
volume_level: VolumeLevel::Loud,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&settings).unwrap();
|
||||
|
||||
@@ -24,6 +24,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("017_downloads_resume_url", MIGRATION_017),
|
||||
("018_items_is_folder", MIGRATION_018),
|
||||
("019_genres_cache", MIGRATION_019),
|
||||
("020_items_season_index", MIGRATION_020),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -281,6 +282,7 @@ CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
|
||||
@@ -714,3 +716,15 @@ CREATE TABLE IF NOT EXISTS genres (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
|
||||
"#;
|
||||
|
||||
/// Migration to index `items.season_id`.
|
||||
///
|
||||
/// Episodes link to their season via `season_id` (parent_id is NULL in the
|
||||
/// cache). The container-rollup queries used by the Downloaded browse and the
|
||||
/// disk-usage aggregation join `children.season_id = c.id`, which without this
|
||||
/// index degrades to an unindexable scan — a large synced catalog then makes
|
||||
/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
|
||||
/// `album_id`, and `series_id` were already indexed; this closes the gap.
|
||||
const MIGRATION_020: &str = r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
|
||||
"#;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.0.16",
|
||||
"version": "0.1.1",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm"],
|
||||
"targets": ["deb", "rpm", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+357
-22
@@ -173,6 +173,16 @@ async playerSetAudioSettings(settings: AudioSettings) : Promise<AudioSettings> {
|
||||
async playerGetAudioSettings() : Promise<AudioSettings> {
|
||||
return await TAURI_INVOKE("player_get_audio_settings");
|
||||
},
|
||||
/**
|
||||
* The built-in equalizer presets and their per-band gain curves (dB), for the
|
||||
* settings UI. The curve numbers are domain data defined by the band layout,
|
||||
* so the frontend reads them here rather than encoding them.
|
||||
*
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
async playerGetEqPresets() : Promise<([EqPreset, number[]])[]> {
|
||||
return await TAURI_INVOKE("player_get_eq_presets");
|
||||
},
|
||||
async playerSetVideoSettings(settings: VideoSettings) : Promise<VideoSettings> {
|
||||
return await TAURI_INVOKE("player_set_video_settings", { settings });
|
||||
},
|
||||
@@ -670,15 +680,15 @@ async storageDeleteUser(userId: string) : Promise<null> {
|
||||
* Update playback progress in local database
|
||||
* This stores the progress locally for offline access and "continue watching"
|
||||
*/
|
||||
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionTicks });
|
||||
async storageUpdatePlaybackProgress(userId: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_progress", { userId, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Update playback progress with context in local database
|
||||
* This stores the progress along with playback context (container vs single)
|
||||
*/
|
||||
async storageUpdatePlaybackContext(userId: string, itemId: string, positionTicks: number, contextType: string | null, contextId: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionTicks, contextType, contextId });
|
||||
async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: number, contextType: string | null, contextId: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_update_playback_context", { userId, itemId, positionMs, contextType, contextId });
|
||||
},
|
||||
/**
|
||||
* Mark item as played in local database
|
||||
@@ -784,6 +794,19 @@ async deleteAllDownloads(userId: string) : Promise<number> {
|
||||
async deleteAlbumDownloads(albumId: string, userId: string) : Promise<number> {
|
||||
return await TAURI_INVOKE("delete_album_downloads", { albumId, userId });
|
||||
},
|
||||
/**
|
||||
* Remove every completed download at or under a container item.
|
||||
*
|
||||
* Works at any level of the Downloaded browse: a leaf (removes just that
|
||||
* download), an album/season/series (removes all downloaded descendants linked
|
||||
* via album_id/season_id/series_id/parent_id). Deletes the DB rows and the
|
||||
* on-disk files. Returns the number of downloads removed. Idempotent.
|
||||
*
|
||||
* TRACES: UR-055 | DR-083
|
||||
*/
|
||||
async deleteDownloadsUnder(itemId: string, userId: string) : Promise<number> {
|
||||
return await TAURI_INVOKE("delete_downloads_under", { itemId, userId });
|
||||
},
|
||||
/**
|
||||
* Clear all stale pending/failed/paused downloads
|
||||
*/
|
||||
@@ -915,6 +938,29 @@ async updateSmartCacheConfig(config: CacheConfig) : Promise<null> {
|
||||
async getSmartCacheConfig() : Promise<CacheConfig> {
|
||||
return await TAURI_INVOKE("get_smart_cache_config");
|
||||
},
|
||||
/**
|
||||
* Report the device's current network transport (Android → Rust).
|
||||
*
|
||||
* The frontend calls this on startup and whenever the native network callback
|
||||
* fires. Updating to an acceptable network re-pumps the download queue, so a
|
||||
* queue parked on "waiting for WiFi" drains itself without user action.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
async setNetworkState(network: NetworkStateWrapperArg) : Promise<null> {
|
||||
return await TAURI_INVOKE("set_network_state", { network });
|
||||
},
|
||||
/**
|
||||
* Whether downloads are currently permitted by the WiFi-only gate.
|
||||
*
|
||||
* The downloads UI uses this to render "Waiting for WiFi" on pending rows
|
||||
* rather than leaving them looking silently stuck.
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
async getDownloadsAllowed() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("get_downloads_allowed");
|
||||
},
|
||||
/**
|
||||
* Get album recommendations based on play history
|
||||
*/
|
||||
@@ -1166,6 +1212,33 @@ async repositoryGetItems(handle: string, parentId: string, options: GetItemsOpti
|
||||
async repositoryGetItem(handle: string, itemId: string) : Promise<MediaItem> {
|
||||
return await TAURI_INVOKE("repository_get_item", { handle, itemId });
|
||||
},
|
||||
/**
|
||||
* Downloaded-only browse: libraries that contain downloaded content.
|
||||
*
|
||||
* Backs the Downloads "Downloaded" surface. Never merges server results and is
|
||||
* authoritative — an empty list means nothing is downloaded.
|
||||
*
|
||||
* TRACES: UR-055 | DR-082
|
||||
*/
|
||||
async repositoryGetDownloadedLibraries(handle: string) : Promise<Library[]> {
|
||||
return await TAURI_INVOKE("repository_get_downloaded_libraries", { handle });
|
||||
},
|
||||
/**
|
||||
* Downloaded-only browse: items under a container that are on the device.
|
||||
*
|
||||
* TRACES: UR-055 | DR-082, DR-083
|
||||
*/
|
||||
async repositoryGetDownloadedItems(handle: string, parentId: string, options: GetItemsOptions | null) : Promise<SearchResult> {
|
||||
return await TAURI_INVOKE("repository_get_downloaded_items", { handle, parentId, options });
|
||||
},
|
||||
/**
|
||||
* On-disk usage of downloaded content (device total, per-item/container bytes).
|
||||
*
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
async repositoryGetDownloadDiskUsage(handle: string) : Promise<DownloadDiskUsage> {
|
||||
return await TAURI_INVOKE("repository_get_download_disk_usage", { handle });
|
||||
},
|
||||
/**
|
||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
||||
* (seconds) in an item. Returns an empty list when JRay isn't installed or
|
||||
@@ -1269,20 +1342,20 @@ async repositoryOpenLiveStream(handle: string, itemId: string) : Promise<LiveStr
|
||||
/**
|
||||
* Report playback start
|
||||
*/
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStart(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_start", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback progress
|
||||
*/
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackProgress(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_progress", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Report playback stopped
|
||||
*/
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionTicks: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionTicks });
|
||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||
},
|
||||
/**
|
||||
* Get image URL for an item
|
||||
@@ -1507,7 +1580,16 @@ normalizeVolume: boolean;
|
||||
/**
|
||||
* Target volume level for normalization
|
||||
*/
|
||||
volumeLevel: VolumeLevel }
|
||||
volumeLevel: VolumeLevel;
|
||||
/**
|
||||
* Enable the graphic equalizer. When false, no EQ filter is applied.
|
||||
*/
|
||||
equalizerEnabled?: boolean;
|
||||
/**
|
||||
* Per-band gains in dB, one per [`EQ_BANDS`]. Normalised to 10 entries and
|
||||
* clamped to [`EQ_GAIN_MIN`, `EQ_GAIN_MAX`] via [`Self::with_equalizer_normalised`].
|
||||
*/
|
||||
equalizerBands?: number[] }
|
||||
/**
|
||||
* Response for audio track switching operations
|
||||
*/
|
||||
@@ -1626,6 +1708,34 @@ connectionError: string | null;
|
||||
* Whether we're currently checking connectivity
|
||||
*/
|
||||
isChecking: boolean }
|
||||
/**
|
||||
* On-disk usage of downloaded content, for the Downloads surface.
|
||||
*
|
||||
* `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
|
||||
* own file size, a container's summed downloaded descendants. `device_total_bytes`
|
||||
* and `item_count` are the headline figures for the Downloaded surface top bar.
|
||||
*
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
export type DownloadDiskUsage = {
|
||||
/**
|
||||
* item id → bytes on disk (leaf's own size, or a container's subtotal).
|
||||
*/
|
||||
sizes: Partial<{ [key in string]: number }>;
|
||||
/**
|
||||
* Container id → true when it is only *partially* downloaded (has cached
|
||||
* children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
|
||||
* the Downloaded surface badge partial vs. full containers.
|
||||
*/
|
||||
partialContainers: Partial<{ [key in string]: boolean }>;
|
||||
/**
|
||||
* Sum of all downloaded leaf sizes — the device total.
|
||||
*/
|
||||
deviceTotalBytes: number;
|
||||
/**
|
||||
* Number of downloaded leaf items (not containers).
|
||||
*/
|
||||
itemCount: number }
|
||||
/**
|
||||
* Information about a download
|
||||
*/
|
||||
@@ -1655,6 +1765,14 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
|
||||
* Enhanced response with pre-computed stats
|
||||
*/
|
||||
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
|
||||
/**
|
||||
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
|
||||
* layout above (a domain concept), not a mere label — the curve numbers live
|
||||
* in Rust so the frontend never encodes the taxonomy.
|
||||
*
|
||||
* TRACES: UR-027 | DR-030
|
||||
*/
|
||||
export type EqPreset = "flat" | "rock" | "pop" | "jazz" | "classical" | "bassBoost" | "trebleBoost" | "vocal"
|
||||
/**
|
||||
* Genre
|
||||
*/
|
||||
@@ -1711,7 +1829,22 @@ export type LmsSyncGroup = { masterMac: string; masterName?: string; slaveMacs?:
|
||||
/**
|
||||
* Media item
|
||||
*/
|
||||
export type MediaItem = { id: string; name: string; type: string;
|
||||
export type MediaItem = { id: string; name: string;
|
||||
/**
|
||||
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
|
||||
*
|
||||
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
|
||||
* is the neutral replacement. This field stays while the frontend migrates
|
||||
* off it, then is removed in a later phase. New Rust code should read
|
||||
* `kind`, not this.
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Provider-neutral classification — the replacement for `item_type`.
|
||||
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
|
||||
* construction sites that have not been migrated yet.
|
||||
*/
|
||||
kind?: MediaKind;
|
||||
/**
|
||||
* Whether this item is a folder/container (vs a playable leaf). Used to
|
||||
* decide whether a channel item drills into a list or plays directly.
|
||||
@@ -1721,7 +1854,63 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
|
||||
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
|
||||
* podcast episodes by release date.
|
||||
*/
|
||||
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
|
||||
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
|
||||
/**
|
||||
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
|
||||
* `duration_ms`; dual-carried while the frontend migrates
|
||||
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
|
||||
*/
|
||||
runTimeTicks?: number | null;
|
||||
/**
|
||||
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
|
||||
* Ticks never reach the frontend; this does.
|
||||
*/
|
||||
durationMs?: number | null;
|
||||
/**
|
||||
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
|
||||
* dual-carried while the frontend migrates. New code should read `image_id`.
|
||||
*/
|
||||
primaryImageTag?: string | null;
|
||||
/**
|
||||
* Neutral image identifier the frontend resolves to a URL via the image
|
||||
* command — the replacement for `primary_image_tag`. Same value today
|
||||
* (Jellyfin's tag is the id); the rename removes the provider term.
|
||||
*/
|
||||
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }
|
||||
/**
|
||||
* The kind of a media item — provider-neutral classification.
|
||||
*
|
||||
* Replaces the stringly-typed `item_type` that carried Jellyfin's vocabulary
|
||||
* (`"Audio"`, `"MusicAlbum"`, …) across the boundary. A closed enum means a
|
||||
* typo or an unhandled kind is a compile error on the frontend, not a silent
|
||||
* runtime miss across ~127 comparison sites.
|
||||
*/
|
||||
export type MediaKind = "track" | "album" | "artist" | "playlist" | "movie" | "series" | "season" | "episode" | "person" |
|
||||
/**
|
||||
* A channel *container* the user drills into (Jellyfin `Channel`).
|
||||
*/
|
||||
"channel" | "folder" |
|
||||
/**
|
||||
* A live TV channel — playable, but a live stream with no seekable
|
||||
* timeline (no resume/seek). Jellyfin `TvChannel`/`LiveTvChannel`.
|
||||
*/
|
||||
"liveChannel" |
|
||||
/**
|
||||
* A playable leaf inside a channel (Jellyfin `ChannelFolderItem` that is
|
||||
* not itself a folder) — e.g. a plugin-channel VOD item that has no
|
||||
* dedicated item type but carries its own media streams. Playable and
|
||||
* seekable, unlike `LiveChannel`. Distinct from `Channel` (the container)
|
||||
* and from `Other` so the UI can route it to playback.
|
||||
*/
|
||||
"channelItem" |
|
||||
/**
|
||||
* A kind we do not model explicitly. Reached only for provider item types
|
||||
* that map to nothing meaningful; consumers treat it like an opaque
|
||||
* container. The mapping must be *total* — it never panics — so this is the
|
||||
* safe sink for unknown strings. Also the `Default`, so a defaulted
|
||||
* `MediaItem` (see the dual-carry migration) is inert rather than a lie.
|
||||
*/
|
||||
"other"
|
||||
/**
|
||||
* Media session type tracking the high-level playback context
|
||||
*/
|
||||
@@ -1750,13 +1939,65 @@ export type MediaSource = { id: string; name: string; container?: string | null;
|
||||
/**
|
||||
* Media stream information (audio, video, subtitle tracks)
|
||||
*/
|
||||
export type MediaStream = { type: string; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
|
||||
export type MediaStream = {
|
||||
/**
|
||||
* Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
|
||||
* replaced by `kind`; dual-carried while the frontend migrates.
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Provider-neutral stream classification — replaces `stream_type`.
|
||||
*/
|
||||
kind?: StreamKind; codec?: string | null; language?: string | null; displayTitle?: string | null; index: number; isDefault: boolean; isForced: boolean }
|
||||
export type MediaType = "audio" | "video"
|
||||
/**
|
||||
* Lightweight media item for merged playback state
|
||||
* Converts from both local MediaItem and remote NowPlayingItem
|
||||
*/
|
||||
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: string }
|
||||
export type MergedMediaItem = { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null;
|
||||
/**
|
||||
* Neutral image identifier — replaces `primary_image_tag` (same value).
|
||||
*/
|
||||
imageId: string | null; mediaType: string }
|
||||
/**
|
||||
* Argument struct for [`set_network_state`].
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export type NetworkStateWrapperArg = { networkType: NetworkType; unmetered: boolean }
|
||||
/**
|
||||
* Kind of network transport currently active.
|
||||
*
|
||||
* Mirrors the string constants in `NetworkTypeMonitor.kt`; the two must stay
|
||||
* in sync (the serde rename below is what the frontend sends).
|
||||
*
|
||||
* TRACES: UR-053 | DR-074
|
||||
*/
|
||||
export type NetworkType =
|
||||
/**
|
||||
* No active network.
|
||||
*/
|
||||
"none" |
|
||||
/**
|
||||
* WiFi (may still be metered — check `unmetered`).
|
||||
*/
|
||||
"wifi" |
|
||||
/**
|
||||
* Wired ethernet, typical on Android TV and desktop.
|
||||
*/
|
||||
"ethernet" |
|
||||
/**
|
||||
* Mobile data — never acceptable when wifi-only is enabled.
|
||||
*/
|
||||
"cellular" |
|
||||
/**
|
||||
* Some other transport (VPN over unknown carrier, Bluetooth tethering, …).
|
||||
*/
|
||||
"other" |
|
||||
/**
|
||||
* Could not determine the transport.
|
||||
*/
|
||||
"unknown"
|
||||
export type NowPlayingItem = { id: string | null; name: string | null; runTimeTicks: number | null; album: string | null; albumId: string | null; albumArtist: string | null; artists: string[] | null; imageTags: Partial<{ [key in string]: string }> | null; primaryImageTag: string | null; albumPrimaryImageTag: string | null; Type: string | null }
|
||||
export type OfflineItem = { id: string; name: string; itemType: string; albumId: string | null; albumName: string | null; artists: string | null; runtimeTicks: number | null; primaryImageTag: string | null }
|
||||
/**
|
||||
@@ -1814,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
|
||||
* handoff so the lockscreen MediaSession advertises a real duration — a
|
||||
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||
*/
|
||||
durationSeconds?: number | null }
|
||||
durationSeconds?: number | null;
|
||||
/**
|
||||
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
|
||||
* background-audio handoff so an episode played as audio-only is still
|
||||
* recognised as an episode by autoplay (UR-040) and advances to the next one.
|
||||
*/
|
||||
itemType?: string | null;
|
||||
/**
|
||||
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||
* look up the next episode when a background-audio track ends.
|
||||
*/
|
||||
seriesId?: string | null }
|
||||
/**
|
||||
* Queue context for remote transfer - what type of queue is this?
|
||||
*/
|
||||
@@ -1865,7 +2117,12 @@ export type PlaybackMode = { type: "local" } | { type: "remote"; session_id: str
|
||||
/**
|
||||
* Playback progress info
|
||||
*/
|
||||
export type PlaybackProgress = { itemId: string; positionTicks: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
|
||||
export type PlaybackProgress = { itemId: string;
|
||||
/**
|
||||
* Resume position in milliseconds. Stored as Jellyfin ticks in the DB;
|
||||
* converted here so the frontend never sees ticks.
|
||||
*/
|
||||
positionMs: number; isPlayed: boolean; isFavorite: boolean; playCount: number }
|
||||
/**
|
||||
* Represents a media item that can be played
|
||||
*
|
||||
@@ -1909,9 +2166,17 @@ artistItems?: ArtistItem[] | null;
|
||||
*/
|
||||
artists?: string[] | null;
|
||||
/**
|
||||
* Primary image tag for artwork
|
||||
* Primary image tag for artwork.
|
||||
*
|
||||
* Legacy Jellyfin name; being replaced by `image_id` (same value). Dual-carried
|
||||
* while the frontend migrates (docs/specs/frontend-domain-model.md).
|
||||
*/
|
||||
primaryImageTag?: string | null;
|
||||
/**
|
||||
* Neutral image identifier the frontend resolves to a URL — replaces
|
||||
* `primary_image_tag`.
|
||||
*/
|
||||
imageId?: string | null;
|
||||
/**
|
||||
* Item type (Audio, Movie, Episode, etc.)
|
||||
*/
|
||||
@@ -2129,7 +2394,19 @@ export type PlayerStatusEvent =
|
||||
* or remote so they can pause/play/seek/stop the webview element.
|
||||
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
||||
*/
|
||||
{ type: "control_command"; action: string; position: number | null }
|
||||
{ type: "control_command"; action: string; position: number | null } |
|
||||
/**
|
||||
* Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
*
|
||||
* Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
* element in the webview, mirroring how all video already renders through
|
||||
* the webview `<video>`. The element then reports its state/position back
|
||||
* through the `player_report_*` commands, so the Rust controller stays the
|
||||
* single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
* via `ControlCommand`.
|
||||
*/
|
||||
{ type: "webview_audio_load"; url: string; media_id: string | null; position: number; autoplay: boolean }
|
||||
/**
|
||||
* Result of creating a playlist
|
||||
*
|
||||
@@ -2146,7 +2423,22 @@ export type PlaylistEntry =
|
||||
/**
|
||||
* The underlying media item
|
||||
*/
|
||||
({ id: string; name: string; type: string;
|
||||
({ id: string; name: string;
|
||||
/**
|
||||
* Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
|
||||
*
|
||||
* Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
|
||||
* is the neutral replacement. This field stays while the frontend migrates
|
||||
* off it, then is removed in a later phase. New Rust code should read
|
||||
* `kind`, not this.
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* Provider-neutral classification — the replacement for `item_type`.
|
||||
* Populated by the Jellyfin mapping; defaults to `Other` for the handful of
|
||||
* construction sites that have not been migrated yet.
|
||||
*/
|
||||
kind?: MediaKind;
|
||||
/**
|
||||
* Whether this item is a folder/container (vs a playable leaf). Used to
|
||||
* decide whether a channel item drills into a list or plays directly.
|
||||
@@ -2156,7 +2448,29 @@ isFolder?: boolean; serverId: string; parentId?: string | null; libraryId?: stri
|
||||
* ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
|
||||
* podcast episodes by release date.
|
||||
*/
|
||||
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null; runTimeTicks?: number | null; primaryImageTag?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
|
||||
premiereDate?: string | null; communityRating?: number | null; officialRating?: string | null;
|
||||
/**
|
||||
* Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
|
||||
* `duration_ms`; dual-carried while the frontend migrates
|
||||
* (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
|
||||
*/
|
||||
runTimeTicks?: number | null;
|
||||
/**
|
||||
* Duration in milliseconds — the neutral replacement for `runtime_ticks`.
|
||||
* Ticks never reach the frontend; this does.
|
||||
*/
|
||||
durationMs?: number | null;
|
||||
/**
|
||||
* Legacy Jellyfin primary image tag. Being replaced by `image_id`;
|
||||
* dual-carried while the frontend migrates. New code should read `image_id`.
|
||||
*/
|
||||
primaryImageTag?: string | null;
|
||||
/**
|
||||
* Neutral image identifier the frontend resolves to a URL via the image
|
||||
* command — the replacement for `primary_image_tag`. Same value today
|
||||
* (Jellyfin's tag is the id); the rename removes the provider term.
|
||||
*/
|
||||
imageId?: string | null; backdropImageTags?: string[] | null; parentBackdropImageTags?: string[] | null; albumId?: string | null; albumName?: string | null; albumArtist?: string | null; artists?: string[] | null; artistItems?: ArtistItem[] | null; indexNumber?: number | null; parentIndexNumber?: number | null; seriesId?: string | null; seriesName?: string | null; seasonId?: string | null; seasonName?: string | null; userData?: UserData | null; mediaStreams?: MediaStream[] | null; mediaSources?: MediaSource[] | null; people?: Person[] | null }) & {
|
||||
/**
|
||||
* The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
|
||||
*/
|
||||
@@ -2261,6 +2575,15 @@ export type SmartCacheStats = { total_size: number; storage_limit: number; avail
|
||||
* Storage statistics for downloads
|
||||
*/
|
||||
export type StorageStats = { total_bytes: number; total_items: number; albums: AlbumStorageInfo[] }
|
||||
/**
|
||||
* The kind of a media stream within an item (audio track, video track,
|
||||
* subtitle, …) — provider-neutral, replacing the stringly Jellyfin stream type.
|
||||
*/
|
||||
export type StreamKind = "audio" | "video" | "subtitle" |
|
||||
/**
|
||||
* Any stream kind we do not model explicitly (e.g. embedded image, data).
|
||||
*/
|
||||
"other"
|
||||
/**
|
||||
* Represents a subtitle track
|
||||
*/
|
||||
@@ -2300,7 +2623,19 @@ export type User = { id: string; name: string; serverId: string; primaryImageTag
|
||||
/**
|
||||
* User-specific data for an item (playback state, favorites, etc.)
|
||||
*/
|
||||
export type UserData = { playbackPositionTicks?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
|
||||
export type UserData = {
|
||||
/**
|
||||
* Legacy Jellyfin resume position in ticks. Being replaced by
|
||||
* `playback_position_ms`; dual-carried while the frontend migrates
|
||||
* (docs/specs/frontend-domain-model.md). New code should read the ms field.
|
||||
*/
|
||||
playbackPositionTicks?: number | null;
|
||||
/**
|
||||
* Resume position in milliseconds — the neutral replacement for
|
||||
* `playback_position_ticks`. Populated from ticks by the mapping; the
|
||||
* frontend never divides ticks itself.
|
||||
*/
|
||||
playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: boolean | null; playCount?: number | null; lastPlayedDate?: string | null; playbackContextType?: string | null; playbackContextId?: string | null }
|
||||
/**
|
||||
* User info returned to frontend
|
||||
*/
|
||||
|
||||
@@ -337,6 +337,46 @@ describe("RepositoryClient", () => {
|
||||
requestId: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Downloaded-only browse path (UR-055 | DR-082) — verifies command names and
|
||||
// camelCase params per the Tauri v2 rule (CLAUDE.md).
|
||||
it("should get downloaded libraries from backend", async () => {
|
||||
const mockLibraries = [{ id: "lib1", name: "Music", collectionType: "music" }];
|
||||
(invoke as any).mockResolvedValueOnce(mockLibraries);
|
||||
|
||||
const libraries = await client.getDownloadedLibraries();
|
||||
|
||||
expect(libraries).toEqual(mockLibraries);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_libraries", {
|
||||
handle: "test-handle-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should get downloaded items with camelCase params", async () => {
|
||||
const mockResult = { items: [{ id: "t1", name: "Track", type: "Audio" }], totalRecordCount: 1 };
|
||||
(invoke as any).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await client.getDownloadedItems("album1", { limit: 50 });
|
||||
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_downloaded_items", {
|
||||
handle: "test-handle-123",
|
||||
parentId: "album1",
|
||||
options: { limit: 50 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should get download disk usage from backend", async () => {
|
||||
const mockUsage = { sizes: { t1: 1000 }, partialContainers: {}, deviceTotalBytes: 1000, itemCount: 1 };
|
||||
(invoke as any).mockResolvedValueOnce(mockUsage);
|
||||
|
||||
const usage = await client.getDownloadDiskUsage();
|
||||
|
||||
expect(usage).toEqual(mockUsage);
|
||||
expect(invoke).toHaveBeenCalledWith("repository_get_download_disk_usage", {
|
||||
handle: "test-handle-123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playback Methods", () => {
|
||||
@@ -430,7 +470,7 @@ describe("RepositoryClient", () => {
|
||||
expect(invoke).toHaveBeenCalledWith("repository_report_playback_progress", {
|
||||
handle: "test-handle-123",
|
||||
itemId: "item123",
|
||||
positionTicks: 5000000,
|
||||
positionMs: 5000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// NO direct HTTP calls - everything routes through Rust backend
|
||||
|
||||
import { commands } from "./bindings";
|
||||
import type { JRayActor } from "./bindings";
|
||||
import type { JRayActor, DownloadDiskUsage } from "./bindings";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import type {
|
||||
Library,
|
||||
@@ -91,6 +91,31 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetItem(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloaded-only browse: libraries that contain downloaded content.
|
||||
* Never merges server results; an empty list is authoritative.
|
||||
* TRACES: UR-055 | DR-082
|
||||
*/
|
||||
async getDownloadedLibraries(): Promise<Library[]> {
|
||||
return commands.repositoryGetDownloadedLibraries(this.ensureHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloaded-only browse: items under a container that are on the device.
|
||||
* TRACES: UR-055 | DR-082, DR-083
|
||||
*/
|
||||
async getDownloadedItems(parentId: string, options?: GetItemsOptions): Promise<SearchResult> {
|
||||
return commands.repositoryGetDownloadedItems(this.ensureHandle(), parentId, options ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk usage of downloaded content (device total + per-item/container bytes).
|
||||
* TRACES: UR-056 | DR-085
|
||||
*/
|
||||
async getDownloadDiskUsage(): Promise<DownloadDiskUsage> {
|
||||
return commands.repositoryGetDownloadDiskUsage(this.ensureHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the optional JRay plugin for the actors on screen at time `t`
|
||||
* (seconds) in an item. Resolves to an empty array when JRay isn't installed
|
||||
@@ -139,16 +164,16 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetPlaybackInfo(this.ensureHandle(), itemId);
|
||||
}
|
||||
|
||||
async reportPlaybackStart(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStart(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStart(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackProgress(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackProgress(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackProgress(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
async reportPlaybackStopped(itemId: string, positionTicks: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionTicks);
|
||||
async reportPlaybackStopped(itemId: string, positionMs: number): Promise<void> {
|
||||
await commands.repositoryReportPlaybackStopped(this.ensureHandle(), itemId, positionMs);
|
||||
}
|
||||
|
||||
// ===== Stream URL Methods (via Rust) =====
|
||||
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
Library,
|
||||
LiveStreamInfo,
|
||||
MediaItem,
|
||||
MediaKind,
|
||||
MediaSource,
|
||||
MediaStream,
|
||||
Person,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
Shared application header. Lifted out of the library layout so the account
|
||||
menu (and desktop nav) are available on every authenticated, non-immersive
|
||||
screen, not only under /library. Routes that need in-header search (the
|
||||
library layout) pass it in via the `search` snippet; other routes omit it.
|
||||
|
||||
TRACES: UR-054 | DR-076
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
|
||||
|
||||
let { search }: { search?: Snippet } = $props();
|
||||
|
||||
const pathname = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
|
||||
<div class="px-4 py-3 flex items-center gap-4">
|
||||
<!-- Logo -->
|
||||
<a href="/library" class="text-xl font-bold text-[var(--color-jellyfin)]">
|
||||
JellyTau
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href="/"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname.startsWith('/library') ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Library
|
||||
</a>
|
||||
<a
|
||||
href="/downloads"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/downloads' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
class="px-3 py-2 rounded-lg text-sm transition-colors hover:bg-[var(--color-surface)] {pathname === '/settings' ? 'text-[var(--color-jellyfin)] bg-[var(--color-surface)]' : 'text-gray-400'}"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Optional in-header search (library layout supplies it). -->
|
||||
{#if search}
|
||||
<div class="flex-1 max-w-md hidden md:block space-y-2">
|
||||
{@render search()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Account menu, anchored to the user's identity. -->
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<!-- Desktop: Downloads quick icon (kept per UX spec §1.2). -->
|
||||
<a
|
||||
href="/downloads"
|
||||
class="hidden md:block text-gray-400 hover:text-white transition-colors"
|
||||
title="Downloads"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type?: "card" | "text" | "circle" | "banner" | "row";
|
||||
count?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
aspectRatio?: "square" | "video" | "portrait";
|
||||
}
|
||||
|
||||
let {
|
||||
type = "card",
|
||||
count = 1,
|
||||
width = "100%",
|
||||
height = "auto",
|
||||
aspectRatio = "square",
|
||||
}: Props = $props();
|
||||
|
||||
const aspectClasses = {
|
||||
square: "aspect-square",
|
||||
video: "aspect-video",
|
||||
portrait: "aspect-[2/3]",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if type === "card"}
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex-shrink-0 w-36 animate-pulse">
|
||||
<div class="w-full {aspectClasses[aspectRatio]} bg-[var(--color-surface)] rounded-lg shimmer"></div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 80%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "banner"}
|
||||
<div class="animate-pulse">
|
||||
<div class="h-[500px] bg-[var(--color-surface)] rounded-xl shimmer"></div>
|
||||
</div>
|
||||
{:else if type === "circle"}
|
||||
<div class="flex gap-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex flex-col items-center animate-pulse">
|
||||
<div class="w-20 h-20 rounded-full bg-[var(--color-surface)] shimmer"></div>
|
||||
<div class="mt-2 h-3 w-16 bg-[var(--color-surface)] rounded shimmer"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "row"}
|
||||
<div class="space-y-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex gap-4 animate-pulse">
|
||||
<div class="w-16 h-16 rounded bg-[var(--color-surface)] shimmer flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2 py-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 70%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 50%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "text"}
|
||||
<div class="space-y-2 animate-pulse">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: {width}; height: {height}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-surface) 0%,
|
||||
rgba(255, 255, 255, 0.05) 20%,
|
||||
var(--color-surface) 40%,
|
||||
var(--color-surface) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
Shared account menu — one component for both breakpoints. Anchored to the
|
||||
user's name/avatar, it groups the account-level destinations (Downloads,
|
||||
Settings, Display) and Sign out. Available on every authenticated,
|
||||
non-immersive screen via the shared AppHeader.
|
||||
|
||||
TRACES: UR-054 | DR-075
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, currentUser, serverName, serverUrl } from "$lib/stores/auth";
|
||||
import { library } from "$lib/stores/library";
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
// Prefer the human server name; fall back to the bare host of the URL so the
|
||||
// identity block always shows *something* server-identifying.
|
||||
const serverHost = $derived.by(() => {
|
||||
if ($serverName) return $serverName;
|
||||
if (!$serverUrl) return "";
|
||||
try {
|
||||
return new URL($serverUrl).host;
|
||||
} catch {
|
||||
return $serverUrl;
|
||||
}
|
||||
});
|
||||
|
||||
const displayName = $derived($currentUser?.name ?? "Account");
|
||||
const initial = $derived((displayName[0] ?? "?").toUpperCase());
|
||||
|
||||
async function close(returnFocus = true) {
|
||||
open = false;
|
||||
if (returnFocus) {
|
||||
await tick();
|
||||
triggerEl?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && open) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await close(false);
|
||||
await auth.logout();
|
||||
library.reset();
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
onclick={toggle}
|
||||
class="flex items-center gap-2 rounded-full py-1 pl-1 pr-1 md:pr-3 text-gray-300 hover:text-white hover:bg-[var(--color-surface)] transition-colors"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label="Account menu"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--color-jellyfin)] text-sm font-semibold text-white"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
<span class="hidden md:inline text-sm">{displayName}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<!-- Backdrop closes the menu on any outside click. -->
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => close()}
|
||||
onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close account menu"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="absolute right-0 top-full mt-2 w-56 bg-[var(--color-surface)] rounded-lg shadow-lg border border-gray-700 py-1 z-50"
|
||||
role="menu"
|
||||
>
|
||||
<!-- Identity block — not interactive. -->
|
||||
<div class="px-4 py-3">
|
||||
<p class="text-xs text-gray-500">Signed in as</p>
|
||||
<p class="text-sm font-semibold text-white truncate">{displayName}</p>
|
||||
{#if serverHost}
|
||||
<p class="text-xs text-gray-400 truncate">{serverHost}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<a
|
||||
href="/downloads"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Downloads
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<a
|
||||
href="/settings#display"
|
||||
role="menuitem"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
onclick={() => close(false)}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v10a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM8 20h8" />
|
||||
</svg>
|
||||
Display
|
||||
</a>
|
||||
|
||||
<div class="border-t border-gray-700 my-1"></div>
|
||||
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
role="menuitem"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 text-sm text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
|
||||
// Controllable store shims + spies, declared via vi.hoisted so they exist when
|
||||
// the hoisted vi.mock factories run. A tiny writable shim avoids importing
|
||||
// svelte inside the hoisted block.
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
currentUserStore: shim<{ name: string } | null>({ name: "Ada" }),
|
||||
serverNameStore: shim<string | null>("Home Server"),
|
||||
serverUrlStore: shim<string | null>("https://media.example.com"),
|
||||
logout: vi.fn(async () => {}),
|
||||
reset: vi.fn(),
|
||||
goto: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { logout: h.logout },
|
||||
currentUser: { subscribe: h.currentUserStore.subscribe },
|
||||
serverName: { subscribe: h.serverNameStore.subscribe },
|
||||
serverUrl: { subscribe: h.serverUrlStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/library", () => ({
|
||||
library: { reset: h.reset },
|
||||
}));
|
||||
|
||||
vi.mock("$app/navigation", () => ({ goto: h.goto }));
|
||||
|
||||
import AccountMenu from "./AccountMenu.svelte";
|
||||
|
||||
function openMenu() {
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
fireEvent.click(trigger);
|
||||
return trigger;
|
||||
}
|
||||
|
||||
describe("AccountMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.currentUserStore.set({ name: "Ada" });
|
||||
h.serverNameStore.set("Home Server");
|
||||
h.serverUrlStore.set("https://media.example.com");
|
||||
});
|
||||
|
||||
it("trigger toggles aria-expanded", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = screen.getByRole("button", { name: "Account menu" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders the documented items in order", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
|
||||
});
|
||||
|
||||
it("shows the identity block with name and server host", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("Signed in as")).toBeTruthy();
|
||||
// "Ada" appears in both the trigger label and the identity block.
|
||||
expect(screen.getAllByText("Ada").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Home Server")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to the URL host when no server name is set", async () => {
|
||||
h.serverNameStore.set(null);
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
expect(screen.getByText("media.example.com")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Escape closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("backdrop click closes the menu", async () => {
|
||||
render(AccountMenu);
|
||||
const trigger = openMenu();
|
||||
const backdrop = screen.getByRole("button", { name: "Close account menu" });
|
||||
await fireEvent.click(backdrop);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("Sign out logs out, resets library state, and redirects home", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const signOut = screen.getByRole("menuitem", { name: "Sign out" });
|
||||
await fireEvent.click(signOut);
|
||||
expect(h.logout).toHaveBeenCalledOnce();
|
||||
expect(h.reset).toHaveBeenCalledOnce();
|
||||
expect(h.goto).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("Sign out is the last item, after the routine navigation", async () => {
|
||||
render(AccountMenu);
|
||||
openMenu();
|
||||
const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
|
||||
expect(items[items.length - 1]).toBe("Sign out");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
Downloaded browse surface: the library, filtered to what's on the device.
|
||||
|
||||
Reuses the library's own grid/cards. The top level lists only libraries with
|
||||
downloaded content; drilling into a library shows its downloaded items in the
|
||||
same grid used online. Clicking a leaf/detail item navigates to the shared
|
||||
`/library/[id]` detail page, where Play uses the local file. Per-item and
|
||||
device disk usage ride along via the size labels and the top bar.
|
||||
|
||||
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-085
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Library, MediaItem } from "$lib/api/types";
|
||||
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
|
||||
import { formatBytes } from "$lib/utils/formatBytes";
|
||||
import {
|
||||
downloadedCatalog,
|
||||
downloadedLibraries,
|
||||
downloadedDeviceTotal,
|
||||
downloadedItemCount,
|
||||
} from "$lib/services/downloadedCatalog";
|
||||
|
||||
// Drill state: null = library list; otherwise the library we're inside.
|
||||
let currentLibrary = $state<Library | null>(null);
|
||||
let items = $state<MediaItem[]>([]);
|
||||
let loadingItems = $state(false);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
const loading = $derived($downloadedCatalog.loading);
|
||||
|
||||
onMount(() => {
|
||||
void downloadedCatalog.refresh();
|
||||
});
|
||||
|
||||
async function openLibrary(library: Library) {
|
||||
currentLibrary = library;
|
||||
loadingItems = true;
|
||||
loadError = null;
|
||||
try {
|
||||
items = await downloadedCatalog.loadItems(library.id);
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : "Failed to load downloads";
|
||||
items = [];
|
||||
} finally {
|
||||
loadingItems = false;
|
||||
}
|
||||
}
|
||||
|
||||
function backToLibraries() {
|
||||
currentLibrary = null;
|
||||
items = [];
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
// Containers (album/season/series/box set) drill via the shared detail page,
|
||||
// which is offline-aware; leaves open their detail/play surface there too.
|
||||
function onItemClick(item: MediaItem | Library) {
|
||||
if ("collectionType" in item) {
|
||||
// A Library (top level) — drill in place.
|
||||
void openLibrary(item as Library);
|
||||
return;
|
||||
}
|
||||
goto(`/library/${item.id}`);
|
||||
}
|
||||
|
||||
// A size label for a card, if we have a byte figure for it.
|
||||
function sizeLabelFor(item: MediaItem | Library): string | undefined {
|
||||
const bytes = $downloadedCatalog.sizes[item.id];
|
||||
return bytes && bytes > 0 ? formatBytes(bytes) : undefined;
|
||||
}
|
||||
|
||||
// Remove a downloaded item/container, stating the reclaim amount first.
|
||||
async function removeItem(item: MediaItem | Library) {
|
||||
if (!("type" in item)) return;
|
||||
const bytes = $downloadedCatalog.sizes[item.id] ?? 0;
|
||||
const freed = bytes > 0 ? ` This frees ${formatBytes(bytes)}.` : "";
|
||||
if (!confirm(`Remove “${item.name}” from this device?${freed}`)) return;
|
||||
try {
|
||||
await downloadedCatalog.remove(item.id);
|
||||
// Reload the current library so removed items (and now-empty containers)
|
||||
// drop out of the browse.
|
||||
if (currentLibrary) {
|
||||
items = await downloadedCatalog.loadItems(currentLibrary.id);
|
||||
}
|
||||
} catch (err) {
|
||||
loadError = err instanceof Error ? err.message : "Failed to remove download";
|
||||
}
|
||||
}
|
||||
|
||||
// Full vs partial container badge (leaves get no container badge here).
|
||||
function downloadedBadgeFor(item: MediaItem | Library): "full" | "partial" | undefined {
|
||||
if (!("type" in item)) return undefined;
|
||||
const isContainer = ["MusicAlbum", "Series", "Season", "BoxSet"].includes(item.type);
|
||||
if (!isContainer) return undefined;
|
||||
return $downloadedCatalog.partialContainers[item.id] ? "partial" : "full";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Device total: the headline figure, reconciles with the listed sum. -->
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-200">
|
||||
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
|
||||
on device
|
||||
<span class="text-gray-500">·</span>
|
||||
{$downloadedItemCount}
|
||||
{$downloadedItemCount === 1 ? "item" : "items"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if currentLibrary}
|
||||
<!-- Inside a library: breadcrumb back to the library list. -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<button
|
||||
onclick={backToLibraries}
|
||||
class="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
Downloaded
|
||||
</button>
|
||||
<span class="text-gray-600">/</span>
|
||||
<span class="text-white font-medium">{currentLibrary.name}</span>
|
||||
</div>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
{/if}
|
||||
|
||||
<LibraryGrid
|
||||
items={items.map((i) => i)}
|
||||
loading={loadingItems}
|
||||
showViewToggle={true}
|
||||
musicContent={currentLibrary.collectionType === "music"}
|
||||
{sizeLabelFor}
|
||||
{downloadedBadgeFor}
|
||||
onItemRemove={removeItem}
|
||||
{onItemClick}
|
||||
/>
|
||||
{#if !loadingItems && items.length === 0 && !loadError}
|
||||
<p class="text-center py-8 text-gray-500 text-sm">Nothing downloaded in this library.</p>
|
||||
{/if}
|
||||
{:else if loading}
|
||||
<p class="text-center py-12 text-gray-400">Loading your downloads…</p>
|
||||
{:else if $downloadedLibraries.length === 0}
|
||||
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
|
||||
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
||||
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Browse your library and tap download to save media for offline.
|
||||
</p>
|
||||
<button
|
||||
onclick={() => goto("/library")}
|
||||
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
|
||||
>
|
||||
Go to library
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Library list — only libraries with downloaded content. -->
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{#each $downloadedLibraries as lib (lib.id)}
|
||||
<button
|
||||
onclick={() => openLibrary(lib)}
|
||||
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
|
||||
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
{lib.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface AlbumStorageInfo {
|
||||
album_id: string;
|
||||
album_name: string;
|
||||
artist_name: string | null;
|
||||
bytes_used: number;
|
||||
track_count: number;
|
||||
}
|
||||
|
||||
interface StorageStats {
|
||||
total_bytes: number;
|
||||
total_items: number;
|
||||
albums: AlbumStorageInfo[];
|
||||
}
|
||||
|
||||
let stats = $state<StorageStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let deleting = $state(false);
|
||||
let deletingAlbum = $state<string | null>(null);
|
||||
let showDeleteAllConfirm = $state(false);
|
||||
let showBreakdown = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
loading = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
stats = await commands.getDownloadStorageStats(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load storage stats:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function deleteAllDownloads() {
|
||||
try {
|
||||
deleting = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAllDownloads(userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all downloads:", error);
|
||||
} finally {
|
||||
deleting = false;
|
||||
showDeleteAllConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlbumDownloads(albumId: string) {
|
||||
try {
|
||||
deletingAlbum = albumId;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAlbumDownloads(albumId, userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete album downloads:", error);
|
||||
} finally {
|
||||
deletingAlbum = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAlbumClick(albumId: string) {
|
||||
if (albumId !== "unknown") {
|
||||
goto(`/library/${albumId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-xl p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage</h2>
|
||||
{#if stats && stats.total_items > 0}
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = true)}
|
||||
class="px-4 py-2 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="w-6 h-6 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<!-- Storage Summary -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-white">{formatBytes(stats.total_bytes)}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{stats.total_items} {stats.total_items === 1 ? "item" : "items"} downloaded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Breakdown Toggle -->
|
||||
{#if stats.albums.length > 0}
|
||||
<button
|
||||
onclick={() => (showBreakdown = !showBreakdown)}
|
||||
class="w-full flex items-center justify-between py-3 px-4 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span class="text-sm text-gray-300">Storage by album</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 transition-transform {showBreakdown ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Album Breakdown -->
|
||||
{#if showBreakdown}
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
{#each stats.albums as album (album.album_id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors">
|
||||
<button
|
||||
onclick={() => handleAlbumClick(album.album_id)}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{truncateMiddle(album.album_name, 40)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
||||
</p>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="text-sm text-gray-400">{formatBytes(album.bytes_used)}</span>
|
||||
<button
|
||||
onclick={() => deleteAlbumDownloads(album.album_id)}
|
||||
disabled={deletingAlbum === album.album_id}
|
||||
class="p-1.5 rounded-full text-gray-400 hover:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
title="Delete album downloads"
|
||||
>
|
||||
{#if deletingAlbum === album.album_id}
|
||||
<div class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if stats.total_items === 0}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-gray-400 text-sm">No downloads yet</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Downloaded media will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete All Confirmation Modal -->
|
||||
{#if showDeleteAllConfirm}
|
||||
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Delete All Downloads?</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">
|
||||
This will remove {stats?.total_items || 0} downloaded items and free up {formatBytes(stats?.total_bytes || 0)} of storage. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = false)}
|
||||
class="flex-1 px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteAllDownloads}
|
||||
disabled={deleting}
|
||||
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if deleting}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Deleting...
|
||||
{:else}
|
||||
Delete All
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,10 +7,11 @@
|
||||
title: string;
|
||||
items: MediaItem[];
|
||||
onItemClick?: (item: MediaItem) => void;
|
||||
onItemLongPress?: (item: MediaItem) => void;
|
||||
showAll?: () => void;
|
||||
}
|
||||
|
||||
let { title, items, onItemClick, showAll }: Props = $props();
|
||||
let { title, items, onItemClick, onItemLongPress, showAll }: Props = $props();
|
||||
|
||||
let scrollContainer: HTMLDivElement | null = $state(null);
|
||||
let showLeftArrow = $state(false);
|
||||
@@ -60,6 +61,7 @@
|
||||
size="medium"
|
||||
showProgress={true}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
onLongPress={onItemLongPress ? () => onItemLongPress(item) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
// 2. For episodes, try series/season backdrops
|
||||
if (currentItem.type === "Episode") {
|
||||
if (currentItem.kind === "episode") {
|
||||
if (currentItem.seriesId && currentItem.parentBackdropImageTags?.[0]) {
|
||||
return { itemId: currentItem.seriesId, imageType: "Backdrop" as const, tag: currentItem.parentBackdropImageTags[0] };
|
||||
}
|
||||
@@ -45,17 +45,17 @@
|
||||
}
|
||||
|
||||
// 3. For music tracks, try album backdrop
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
if (currentItem.kind === "track" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Backdrop" as const, tag: undefined };
|
||||
}
|
||||
|
||||
// 4. Fall back to primary image
|
||||
if (currentItem.primaryImageTag) {
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.primaryImageTag };
|
||||
if (currentItem.imageId) {
|
||||
return { itemId: currentItem.id, imageType: "Primary" as const, tag: currentItem.imageId };
|
||||
}
|
||||
|
||||
// 5. Last resort for audio: album primary
|
||||
if (currentItem.type === "Audio" && currentItem.albumId) {
|
||||
if (currentItem.kind === "track" && currentItem.albumId) {
|
||||
return { itemId: currentItem.albumId, imageType: "Primary" as const, tag: undefined };
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
onclick={() => {
|
||||
// Navigate to full series detail page with cast/crew/related content
|
||||
// (even for episodes, show the series page so users see cast and related items)
|
||||
if (currentItem.type === "Episode" && currentItem.seriesId) {
|
||||
if (currentItem.kind === "episode" && currentItem.seriesId) {
|
||||
goto(`/library/${currentItem.seriesId}`);
|
||||
} else {
|
||||
goto(`/library/${currentItem.id}`);
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
sortBy: "DateCreated",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
albums = albumsResult.items.filter(item => item.type === "MusicAlbum");
|
||||
albums = albumsResult.items.filter(item => item.kind === "album");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load albums:", e);
|
||||
} finally {
|
||||
@@ -58,7 +58,7 @@
|
||||
sortBy: "CommunityRating",
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
topTracks = tracksResult.items.filter(item => item.type === "Audio");
|
||||
topTracks = tracksResult.items.filter(item => item.kind === "track");
|
||||
} catch (e) {
|
||||
console.warn("Failed to load tracks:", e);
|
||||
} finally {
|
||||
@@ -76,7 +76,7 @@
|
||||
sortOrder: "Descending"
|
||||
});
|
||||
relatedArtists = relatedResult.items
|
||||
.filter(item => item.id !== artist.id && item.type === "MusicArtist")
|
||||
.filter(item => item.id !== artist.id && item.kind === "artist")
|
||||
.slice(0, 6);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -112,12 +112,12 @@
|
||||
<!-- Artist Info -->
|
||||
<div class="flex flex-col items-center text-center py-12">
|
||||
<!-- Artist Image -->
|
||||
{#if artist.primaryImageTag}
|
||||
{#if artist.imageId}
|
||||
<div class="mb-6 rounded-full overflow-hidden w-40 h-40 shadow-lg">
|
||||
<CachedImage
|
||||
itemId={artist.id}
|
||||
imageType="Primary"
|
||||
tag={artist.primaryImageTag}
|
||||
tag={artist.imageId}
|
||||
maxWidth={400}
|
||||
alt={artist.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -160,11 +160,11 @@
|
||||
class="group cursor-pointer"
|
||||
>
|
||||
<div class="aspect-square bg-[var(--color-surface)] rounded-lg overflow-hidden mb-2 group-hover:opacity-80 transition-opacity">
|
||||
{#if album.primaryImageTag}
|
||||
{#if album.imageId}
|
||||
<CachedImage
|
||||
itemId={album.id}
|
||||
imageType="Primary"
|
||||
tag={album.primaryImageTag}
|
||||
tag={album.imageId}
|
||||
maxWidth={200}
|
||||
alt={album.name}
|
||||
class="w-full h-full object-cover"
|
||||
@@ -218,11 +218,11 @@
|
||||
class="group text-center"
|
||||
>
|
||||
<div class="w-32 h-32 bg-[var(--color-surface)] rounded-full overflow-hidden mb-2 mx-auto group-hover:opacity-80 transition-opacity">
|
||||
{#if relatedArtist.primaryImageTag}
|
||||
{#if relatedArtist.imageId}
|
||||
<CachedImage
|
||||
itemId={relatedArtist.id}
|
||||
imageType="Primary"
|
||||
tag={relatedArtist.primaryImageTag}
|
||||
tag={relatedArtist.imageId}
|
||||
maxWidth={200}
|
||||
alt={relatedArtist.name}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<!-- TRACES: UR-048 | DR-061, DR-062 -->
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
|
||||
|
||||
interface Props {
|
||||
episode: MediaItem;
|
||||
@@ -13,71 +15,20 @@
|
||||
|
||||
let { episode, series, allEpisodes, onBack }: Props = $props();
|
||||
|
||||
// Check if an episode matches the focused episode (by ID or season/episode number)
|
||||
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
|
||||
function isCurrentEpisode(ep: MediaItem): boolean {
|
||||
if (ep.id === episode.id) return true;
|
||||
// Also match by season/episode number in case IDs differ
|
||||
return ep.parentIndexNumber === episode.parentIndexNumber &&
|
||||
ep.indexNumber === episode.indexNumber;
|
||||
return isSameEpisode(ep, episode);
|
||||
}
|
||||
|
||||
// Find adjacent episodes - use season/episode numbers if ID not found
|
||||
const adjacentEpisodes = $derived(() => {
|
||||
// First, try to find the episode by ID
|
||||
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
|
||||
|
||||
// If not found by ID, try to find by season/episode number
|
||||
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
|
||||
idx = allEpisodes.findIndex(
|
||||
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
|
||||
);
|
||||
}
|
||||
|
||||
// If still not found, filter to same season and show those centered around the episode number
|
||||
if (idx === -1) {
|
||||
const sameSeasonEpisodes = allEpisodes
|
||||
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
|
||||
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
|
||||
|
||||
if (sameSeasonEpisodes.length > 0) {
|
||||
// Find position based on episode number
|
||||
const epNum = episode.indexNumber || 1;
|
||||
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
|
||||
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
|
||||
const start = Math.max(0, actualIdx - 3);
|
||||
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
|
||||
const result = sameSeasonEpisodes.slice(start, end);
|
||||
|
||||
// Insert the focused episode if not already present (by season/episode number match)
|
||||
const hasCurrentEpisode = result.some(isCurrentEpisode);
|
||||
if (!hasCurrentEpisode) {
|
||||
// Insert at correct position based on episode number
|
||||
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
|
||||
if (insertIdx === -1) {
|
||||
result.push(episode);
|
||||
} else {
|
||||
result.splice(insertIdx, 0, episode);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Last resort: return focused episode with first 9 episodes
|
||||
return [episode, ...allEpisodes.slice(0, 9)];
|
||||
}
|
||||
|
||||
// Get 3 before and 6 after (or adjust based on position)
|
||||
const start = Math.max(0, idx - 3);
|
||||
const end = Math.min(allEpisodes.length, idx + 7);
|
||||
return allEpisodes.slice(start, end);
|
||||
});
|
||||
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
||||
|
||||
// Compute best backdrop source (no fetch, pure derivation)
|
||||
const backdropSource = $derived.by(() => {
|
||||
if (episode.backdropImageTags?.[0]) {
|
||||
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
||||
}
|
||||
if (episode.primaryImageTag) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.primaryImageTag };
|
||||
if (episode.imageId) {
|
||||
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
||||
}
|
||||
if (series.backdropImageTags?.[0]) {
|
||||
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
||||
@@ -85,9 +36,9 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
function formatDuration(ticks?: number | null): string {
|
||||
if (!ticks) return "";
|
||||
const seconds = Math.floor(ticks / 10000000);
|
||||
function formatDuration(ms?: number | null): string {
|
||||
if (!ms) return "";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -98,10 +49,10 @@
|
||||
}
|
||||
|
||||
function getProgress(ep: MediaItem): number {
|
||||
if (!ep.userData || !ep.runTimeTicks) {
|
||||
if (!ep.userData || !ep.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((ep.userData.playbackPositionTicks ?? 0) / ep.runTimeTicks) * 100;
|
||||
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
@@ -115,7 +66,7 @@
|
||||
const episodeLabel = $derived(
|
||||
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
||||
);
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const progress = $derived(getProgress(episode));
|
||||
</script>
|
||||
|
||||
@@ -245,7 +196,7 @@
|
||||
<CachedImage
|
||||
itemId={ep.id}
|
||||
imageType="Primary"
|
||||
tag={ep.primaryImageTag}
|
||||
tag={ep.imageId}
|
||||
maxWidth={400}
|
||||
alt={ep.name}
|
||||
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
const downloadProgress = $derived(downloadInfo?.progress || 0);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!episode.userData || !episode.runTimeTicks) {
|
||||
if (!episode.userData || !episode.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((episode.userData.playbackPositionTicks ?? 0) / episode.runTimeTicks) * 100;
|
||||
return ((episode.userData.playbackPositionMs ?? 0) / episode.durationMs) * 100;
|
||||
});
|
||||
|
||||
const duration = $derived(formatDuration(episode.runTimeTicks));
|
||||
const duration = $derived(formatDuration(episode.durationMs));
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.primaryImageTag}
|
||||
tag={episode.imageId}
|
||||
maxWidth={320}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<CachedImage
|
||||
itemId={item.id}
|
||||
imageType="Primary"
|
||||
tag={item.primaryImageTag}
|
||||
tag={item.imageId}
|
||||
maxWidth={300}
|
||||
alt={item.name}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import type { MediaKind } from "$lib/api/types";
|
||||
|
||||
interface Props {
|
||||
genres: string[];
|
||||
maxShow?: number; // Default: unlimited
|
||||
clickable?: boolean; // Default: true
|
||||
itemType?: string; // Determines which genre browse page to open
|
||||
itemKind?: MediaKind; // Determines which genre browse page to open
|
||||
}
|
||||
|
||||
let {
|
||||
genres,
|
||||
maxShow,
|
||||
clickable = true,
|
||||
itemType
|
||||
itemKind
|
||||
}: Props = $props();
|
||||
|
||||
// Map the item type to its genre-browse route
|
||||
function genreBasePath(type: string | undefined): string {
|
||||
switch (type) {
|
||||
case "MusicAlbum":
|
||||
case "MusicArtist":
|
||||
case "Audio":
|
||||
// Map the item kind to its genre-browse route
|
||||
function genreBasePath(kind: MediaKind | undefined): string {
|
||||
switch (kind) {
|
||||
case "album":
|
||||
case "artist":
|
||||
case "track":
|
||||
case "playlist":
|
||||
return "/library/music/genres";
|
||||
case "Series":
|
||||
case "Season":
|
||||
case "Episode":
|
||||
case "series":
|
||||
case "season":
|
||||
case "episode":
|
||||
return "/library/shows/genres";
|
||||
case "Movie":
|
||||
case "movie":
|
||||
return "/library/movies/genres";
|
||||
default:
|
||||
return "/library/movies/genres";
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
function handleGenreClick(genre: string) {
|
||||
if (clickable) {
|
||||
goto(`${genreBasePath(itemType)}?genre=${encodeURIComponent(genre)}`);
|
||||
goto(`${genreBasePath(itemKind)}?genre=${encodeURIComponent(genre)}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
@@ -9,12 +10,20 @@
|
||||
title?: string;
|
||||
loading?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
forceGrid?: boolean;
|
||||
musicContent?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
/**
|
||||
* Optional per-item secondary label (e.g. on-disk size for the Downloaded
|
||||
* surface), forwarded to each card. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabelFor?: (item: MediaItem | Library) => string | undefined;
|
||||
/** Optional per-item container download badge for the Downloaded surface. */
|
||||
downloadedBadgeFor?: (item: MediaItem | Library) => "full" | "partial" | undefined;
|
||||
/** Optional per-item remove-from-device handler for the Downloaded surface. */
|
||||
onItemRemove?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
|
||||
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -65,7 +74,7 @@
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{:else if !forceGrid && $viewMode === "list"}
|
||||
{:else if $viewMode === "list"}
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@@ -74,6 +83,9 @@
|
||||
<MediaCard
|
||||
{item}
|
||||
showProgress={true}
|
||||
sizeLabel={sizeLabelFor?.(item)}
|
||||
downloadedBadge={downloadedBadgeFor?.(item)}
|
||||
onRemove={onItemRemove ? () => onItemRemove(item) : undefined}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "primaryImageTag" in item ? (item.primaryImageTag ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
||||
return "imageId" in item ? (item.imageId ?? undefined) : ("imageTag" in item ? (item.imageTag ?? undefined) : undefined);
|
||||
}
|
||||
|
||||
function getSubtitle(item: MediaItem | Library): string {
|
||||
@@ -42,10 +42,10 @@
|
||||
|
||||
|
||||
function getProgress(item: MediaItem | Library): number {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("runTimeTicks" in item) || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !("durationMs" in item) || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
}
|
||||
|
||||
function getTrackNumber(item: MediaItem | Library): string {
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="space-y-1">
|
||||
{#each items as item, index (item.id)}
|
||||
{@const subtitle = getSubtitle(item)}
|
||||
{@const duration = "runTimeTicks" in item ? formatDuration(item.runTimeTicks) : ""}
|
||||
{@const duration = "durationMs" in item ? formatDuration(item.durationMs) : ""}
|
||||
{@const progress = getProgress(item)}
|
||||
{@const trackNum = getTrackNumber(item)}
|
||||
{@const isPlayed = "userData" in item && item.userData?.isPlayed}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -12,10 +13,86 @@
|
||||
size?: "small" | "medium" | "large";
|
||||
showProgress?: boolean;
|
||||
showDownloadStatus?: boolean;
|
||||
/**
|
||||
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
|
||||
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabel?: string;
|
||||
/**
|
||||
* "full" | "partial" — badges a downloaded container on the artwork so a
|
||||
* fully-downloaded item reads differently from a partially-downloaded one.
|
||||
* TRACES: UR-055 | DR-083
|
||||
*/
|
||||
downloadedBadge?: "full" | "partial";
|
||||
/**
|
||||
* When set, a hover/focus "remove from device" control appears on the card
|
||||
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
onclick?: () => void;
|
||||
/**
|
||||
* When set, a long press (touch hold / mouse hold) fires this instead of the
|
||||
* regular tap. The tap that would otherwise follow the release is suppressed.
|
||||
* Used on the home page: tap opens the detail page, long-press plays now.
|
||||
* TRACES: UR-058 | DR-087
|
||||
*/
|
||||
onLongPress?: () => void;
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress }: Props = $props();
|
||||
|
||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||
// flag so the ensuing click is swallowed. Pointer events cover touch + mouse.
|
||||
const LONG_PRESS_MS = 500;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
let pressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let longPressFired = false;
|
||||
let pressStartX = 0;
|
||||
let pressStartY = 0;
|
||||
|
||||
function clearPressTimer() {
|
||||
if (pressTimer !== null) {
|
||||
clearTimeout(pressTimer);
|
||||
pressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (!onLongPress || isServerOnly) return;
|
||||
longPressFired = false;
|
||||
pressStartX = e.clientX;
|
||||
pressStartY = e.clientY;
|
||||
clearPressTimer();
|
||||
pressTimer = setTimeout(() => {
|
||||
longPressFired = true;
|
||||
pressTimer = null;
|
||||
onLongPress?.();
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (pressTimer === null) return;
|
||||
if (
|
||||
Math.abs(e.clientX - pressStartX) > MOVE_CANCEL_PX ||
|
||||
Math.abs(e.clientY - pressStartY) > MOVE_CANCEL_PX
|
||||
) {
|
||||
clearPressTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
clearPressTimer();
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
// A long press already handled this interaction; swallow the trailing click.
|
||||
if (longPressFired) {
|
||||
longPressFired = false;
|
||||
return;
|
||||
}
|
||||
onclick?.();
|
||||
}
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
@@ -84,11 +161,11 @@
|
||||
};
|
||||
|
||||
const isMusicType = $derived(
|
||||
"type" in item && (item.type === "Audio" || item.type === "MusicAlbum" || item.type === "MusicArtist" || item.type === "Playlist")
|
||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||
);
|
||||
|
||||
const aspectRatio = $derived(() => {
|
||||
if ("type" in item) {
|
||||
if ("kind" in item) {
|
||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||
}
|
||||
// Library
|
||||
@@ -96,16 +173,16 @@
|
||||
});
|
||||
|
||||
const imageTag = $derived(
|
||||
"primaryImageTag" in item ? item.primaryImageTag : ("imageTag" in item ? item.imageTag : undefined)
|
||||
"imageId" in item ? item.imageId : ("imageTag" in item ? item.imageTag : undefined)
|
||||
);
|
||||
|
||||
const maxWidth = $derived(size === "large" ? 400 : size === "medium" ? 300 : 200);
|
||||
|
||||
const progress = $derived(() => {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.runTimeTicks) {
|
||||
if (!showProgress || !("userData" in item) || !item.userData || !item.durationMs) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.userData.playbackPositionTicks ?? 0) / item.runTimeTicks) * 100;
|
||||
return ((item.userData.playbackPositionMs ?? 0) / item.durationMs) * 100;
|
||||
});
|
||||
|
||||
const subtitle = $derived(() => {
|
||||
@@ -133,7 +210,13 @@
|
||||
type={isServerOnly ? undefined : "button"}
|
||||
role={isServerOnly ? "group" : undefined}
|
||||
class="group/card flex flex-col text-left {sizeClasses[size]} flex-shrink-0 transition-transform duration-200 {isServerOnly ? '' : 'hover:scale-105'}"
|
||||
onclick={isServerOnly ? undefined : onclick}
|
||||
style={onLongPress ? "touch-action: manipulation; -webkit-touch-callout: none;" : undefined}
|
||||
onclick={isServerOnly ? undefined : handleClick}
|
||||
onpointerdown={isServerOnly ? undefined : handlePointerDown}
|
||||
onpointermove={isServerOnly ? undefined : handlePointerMove}
|
||||
onpointerup={isServerOnly ? undefined : handlePointerUp}
|
||||
onpointercancel={isServerOnly ? undefined : handlePointerUp}
|
||||
oncontextmenu={onLongPress ? (e: Event) => e.preventDefault() : undefined}
|
||||
>
|
||||
<div class="relative {aspectRatio()} w-full rounded-lg overflow-hidden bg-[var(--color-surface)] shadow-md group-hover/card:shadow-2xl transition-shadow duration-200">
|
||||
<CachedImage
|
||||
@@ -219,6 +302,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
|
||||
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
||||
title="Remove from device"
|
||||
aria-label="Remove {item.name} from device"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
|
||||
{#if downloadedBadge}
|
||||
<div
|
||||
class="absolute bottom-2 right-2"
|
||||
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
|
||||
>
|
||||
{#if downloadedBadge === "full"}
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
||||
greyed artwork). Queued items show a "queued" badge instead. -->
|
||||
{#if isServerOnly}
|
||||
@@ -261,5 +381,8 @@
|
||||
{#if subtitle()}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||
{/if}
|
||||
{#if sizeLabel}
|
||||
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
});
|
||||
|
||||
// Separate movies and series
|
||||
movies = result.items.filter(item => item.type === "Movie");
|
||||
series = result.items.filter(item => item.type === "Series");
|
||||
movies = result.items.filter(item => item.kind === "movie");
|
||||
series = result.items.filter(item => item.kind === "series");
|
||||
} catch (e) {
|
||||
console.error("Failed to load filmography:", e);
|
||||
} finally {
|
||||
@@ -53,7 +53,7 @@
|
||||
<CachedImage
|
||||
itemId={person.id}
|
||||
imageType="Primary"
|
||||
tag={person.primaryImageTag}
|
||||
tag={person.imageId}
|
||||
maxWidth={400}
|
||||
alt={person.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
const tracks = $derived(entries.map(e => ({ ...e } as MediaItem)));
|
||||
|
||||
const totalDuration = $derived(
|
||||
entries.reduce((sum, e) => sum + (e.runTimeTicks ?? 0), 0)
|
||||
entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0)
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
@@ -146,11 +146,11 @@
|
||||
<div class="flex gap-6 pt-4">
|
||||
<!-- Playlist artwork -->
|
||||
<div class="flex-shrink-0 w-48">
|
||||
{#if playlist.primaryImageTag}
|
||||
{#if playlist.imageId}
|
||||
<CachedImage
|
||||
itemId={playlist.id}
|
||||
imageType="Primary"
|
||||
tag={playlist.primaryImageTag}
|
||||
tag={playlist.imageId}
|
||||
maxWidth={400}
|
||||
alt={playlist.name}
|
||||
class="w-full rounded-lg shadow-lg"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { MediaItem, Person } from "$lib/api/types";
|
||||
import type { MediaItem, MediaKind, Person } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
|
||||
interface Props {
|
||||
currentItemId: string;
|
||||
itemType: "Movie" | "Series" | "MusicAlbum" | "Audio";
|
||||
itemKind: MediaKind;
|
||||
genres?: string[];
|
||||
people?: Person[];
|
||||
artistIds?: string[];
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
let {
|
||||
currentItemId,
|
||||
itemType,
|
||||
itemKind,
|
||||
genres = [],
|
||||
people = [],
|
||||
artistIds = [],
|
||||
@@ -45,9 +45,9 @@
|
||||
|
||||
let items: MediaItem[] = [];
|
||||
|
||||
// First, try to use the Jellyfin Similar Items API (preferred method)
|
||||
// First, try to use the Similar Items API (preferred method)
|
||||
// This works for Movies and Series (most common cases)
|
||||
if (["Movie", "Series"].includes(itemType)) {
|
||||
if (itemKind === "movie" || itemKind === "series") {
|
||||
try {
|
||||
const result = await repo.getSimilarItems(currentItemId, limit);
|
||||
items = result.items.filter(item => item.id !== currentItemId);
|
||||
@@ -65,10 +65,14 @@
|
||||
// Fallback: Load by genres using search (works for all item types)
|
||||
if (genres && genres.length > 0) {
|
||||
try {
|
||||
// Search by first genre to find related items
|
||||
// Search by first genre to find related items. This single-kind query
|
||||
// maps the neutral kind to the concrete Jellyfin item type it needs.
|
||||
const searchTerm = genres[0];
|
||||
const itemTypeForKind: Record<string, string> = {
|
||||
movie: "Movie", series: "Series", album: "MusicAlbum", track: "Audio", artist: "MusicArtist",
|
||||
};
|
||||
const result = await repo.search(searchTerm, {
|
||||
includeItemTypes: itemType === "MusicAlbum" ? ["MusicAlbum"] : itemType === "Audio" ? ["Audio"] : [itemType],
|
||||
includeItemTypes: [itemTypeForKind[itemKind] ?? "Movie"],
|
||||
limit: limit * 2
|
||||
});
|
||||
|
||||
@@ -79,7 +83,7 @@
|
||||
}
|
||||
|
||||
// For music albums, also try to load by artist (if we don't have enough from similar API)
|
||||
if (itemType === "MusicAlbum" && artistIds && artistIds.length > 0 && items.length === 0) {
|
||||
if (itemKind === "album" && artistIds && artistIds.length > 0 && items.length === 0) {
|
||||
try {
|
||||
// Search for other albums by artist name from first artist
|
||||
const result = await repo.search(artistIds[0], {
|
||||
@@ -109,14 +113,14 @@
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
switch (itemType) {
|
||||
case "Movie":
|
||||
switch (itemKind) {
|
||||
case "movie":
|
||||
return "Related Movies";
|
||||
case "Series":
|
||||
case "series":
|
||||
return "Related Shows";
|
||||
case "MusicAlbum":
|
||||
case "album":
|
||||
return "Related Albums";
|
||||
case "Audio":
|
||||
case "track":
|
||||
return "Related Tracks";
|
||||
default:
|
||||
return "Related Items";
|
||||
@@ -133,7 +137,7 @@
|
||||
|
||||
{#if loading}
|
||||
<!-- Skeleton loading state -->
|
||||
{@const isMusicContent = itemType === "MusicAlbum" || itemType === "Audio"}
|
||||
{@const isMusicContent = itemKind === "album" || itemKind === "track"}
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{#each Array(6) as _}
|
||||
<div class="animate-pulse">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<CachedImage
|
||||
itemId={season.id}
|
||||
imageType="Primary"
|
||||
tag={season.primaryImageTag}
|
||||
tag={season.imageId}
|
||||
maxWidth={200}
|
||||
alt={seasonName}
|
||||
class="w-full h-full object-cover"
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="text-gray-400 text-right">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
|
||||
<!-- Download Button Placeholder -->
|
||||
@@ -421,7 +421,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-gray-400 text-sm {showDownload ? 'mr-20' : 'mr-12'}">
|
||||
{formatDuration(track.runTimeTicks)}
|
||||
{formatDuration(track.durationMs)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 1"],
|
||||
albumName: "Album 1",
|
||||
albumId: "album-1",
|
||||
runTimeTicks: 1800000000, // 3 minutes
|
||||
durationMs: 180000, // 3 minutes
|
||||
primaryImageTag: "tag1",
|
||||
indexNumber: 1,
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 2"],
|
||||
albumName: "Album 2",
|
||||
albumId: "album-2",
|
||||
runTimeTicks: 2400000000, // 4 minutes
|
||||
durationMs: 240000, // 4 minutes
|
||||
primaryImageTag: "tag2",
|
||||
indexNumber: 2,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ describe("TrackList", () => {
|
||||
artists: ["Artist 3", "Artist 4"],
|
||||
albumName: "Album 3",
|
||||
albumId: "album-3",
|
||||
runTimeTicks: 3000000000, // 5 minutes
|
||||
durationMs: 300000, // 5 minutes
|
||||
indexNumber: 3,
|
||||
},
|
||||
];
|
||||
@@ -187,7 +187,7 @@ describe("TrackList", () => {
|
||||
const tracksWithoutDuration: MediaItem[] = [
|
||||
{
|
||||
...mockTracks[0],
|
||||
runTimeTicks: undefined,
|
||||
durationMs: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
|
||||
|
||||
// Minimal episode factory — only the fields the strip logic reads.
|
||||
function ep(
|
||||
id: string,
|
||||
season: number | null,
|
||||
number: number | null,
|
||||
): MediaItem {
|
||||
return {
|
||||
id,
|
||||
name: `S${season}E${number}`,
|
||||
kind: "episode",
|
||||
parentIndexNumber: season,
|
||||
indexNumber: number,
|
||||
} as unknown as MediaItem;
|
||||
}
|
||||
|
||||
function season(n: number, count: number): MediaItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
|
||||
}
|
||||
|
||||
describe("isCurrentEpisode", () => {
|
||||
const current = ep("abc", 1, 3);
|
||||
|
||||
it("matches by id", () => {
|
||||
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches by season+episode number when id differs", () => {
|
||||
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a different episode number", () => {
|
||||
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
|
||||
const a = ep("a", null, null);
|
||||
const b = ep("b", null, null);
|
||||
expect(isCurrentEpisode(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when only one side has numbers", () => {
|
||||
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
|
||||
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adjacentEpisodes", () => {
|
||||
it("returns just the current episode when there are no others", () => {
|
||||
const current = ep("only", 1, 1);
|
||||
expect(adjacentEpisodes(current, [])).toEqual([current]);
|
||||
});
|
||||
|
||||
it("returns siblings, not just the current episode", () => {
|
||||
const eps = season(1, 8);
|
||||
const current = eps[2]; // S1E3
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.length).toBeGreaterThan(1);
|
||||
expect(strip).toContain(current);
|
||||
});
|
||||
|
||||
it("windows to 3 before and 6 after the current episode", () => {
|
||||
const eps = season(1, 20);
|
||||
const current = eps[9]; // S1E10, index 9
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
|
||||
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||
expect(strip).toContain(current);
|
||||
});
|
||||
|
||||
it("restricts to the current season when multiple seasons are present", () => {
|
||||
const eps = [...season(1, 5), ...season(2, 5)];
|
||||
const current = eps[6]; // S2E2
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
|
||||
const eps = season(1, 5);
|
||||
// Focused episode has a different id than any in the list but same numbers.
|
||||
const current = ep("fetched-directly", 1, 3);
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
// It should appear once, anchored at its numeric position, alongside siblings.
|
||||
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
|
||||
expect(strip.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("falls back to the full list when the current season is unknown", () => {
|
||||
const eps = season(1, 5);
|
||||
const current = ep("mystery", null, 3); // no season number
|
||||
const strip = adjacentEpisodes(current, eps);
|
||||
expect(strip.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
|
||||
//
|
||||
// Extracted from the component so it can be unit-tested: the strip must never
|
||||
// collapse to just the current episode while real siblings exist, and it must
|
||||
// not mistake number-less episodes for the current one.
|
||||
//
|
||||
// TRACES: UR-048 | DR-062
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/**
|
||||
* Does `ep` refer to the same episode as `current`?
|
||||
*
|
||||
* Matches by id first. Falls back to season+episode number, but ONLY when both
|
||||
* numbers are known on both sides — otherwise `undefined === undefined` would
|
||||
* mark every number-less episode as the current one (the bug that made the
|
||||
* whole strip look like the current episode).
|
||||
*/
|
||||
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
||||
if (ep.id === current.id) return true;
|
||||
if (
|
||||
ep.indexNumber == null || current.indexNumber == null ||
|
||||
ep.parentIndexNumber == null || current.parentIndexNumber == null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ep.parentIndexNumber === current.parentIndexNumber &&
|
||||
ep.indexNumber === current.indexNumber
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window of episodes shown under the hero: up to 3 before and 6 after the
|
||||
* current episode. Degrades gracefully:
|
||||
* - prefers the current season, falling back to the full list when the season
|
||||
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
|
||||
* - splices the current episode into the pool at its numeric position when it
|
||||
* isn't present, so it still anchors the window;
|
||||
* - returns just `[current]` only when there genuinely are no other episodes.
|
||||
*/
|
||||
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
||||
const seasonMatches = allEpisodes.filter(
|
||||
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
|
||||
);
|
||||
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
|
||||
.slice()
|
||||
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
|
||||
|
||||
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
||||
|
||||
if (idx === -1) {
|
||||
const epNum = current.indexNumber ?? 0;
|
||||
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
|
||||
idx = insertAt === -1 ? pool.length : insertAt;
|
||||
pool.splice(idx, 0, current);
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 3);
|
||||
const end = Math.min(pool.length, idx + 7);
|
||||
return pool.slice(start, end);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user